<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://wiki.expertiza.ncsu.edu/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Jweisz</id>
	<title>Expertiza_Wiki - User contributions [en]</title>
	<link rel="self" type="application/atom+xml" href="https://wiki.expertiza.ncsu.edu/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Jweisz"/>
	<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Special:Contributions/Jweisz"/>
	<updated>2026-08-16T08:01:51Z</updated>
	<subtitle>User contributions</subtitle>
	<generator>MediaWiki 1.41.0</generator>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168165</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168165"/>
		<updated>2026-04-28T22:26:20Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Tests (74 total) */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Provider configuration is validated at boot via config/initializers/oidc.rb. In production, a provider with any missing required key raises OidcConfig::InvalidConfiguration, which prevents the application from starting with a misconfigured OIDC provider. In all other environments (development, test), the invalid provider is skipped with a warning logged so that local development and CI are not blocked when OIDC credentials are not configured.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institutions, some of which may want OIDC and others which may not and there is no institution context pre-login at this time, we felt that probably falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each identity provider is defined as a YAML configuration block with its own credentials, issuer, and scopes. &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt; operate on whatever provider key is passed in, with no &amp;lt;code&amp;gt;if provider == &amp;quot;google&amp;quot;&amp;lt;/code&amp;gt; branching. Adding a new institution's SSO requires only a new YAML entry and environment variables, no code changes anywhere.&lt;br /&gt;
** [https://github.com/johnmweisz/reimplementation-back-end/blob/9bc24c3ca0a13188899b3b72ec3208afcdd8a21b/app/controllers/oidc_login_controller.rb#L34 Source]&lt;br /&gt;
&lt;br /&gt;
* '''Singleton''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: provider configuration is memoized as class-level state and cleared via &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt;, ensuring YAML is parsed once per process. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing additional safety since necessary methods are not available to a new instance anyway.  AI recommended we keep it this way.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic Garbage Collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage (10%) of new request creations rather than scheduled. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests (74 total) ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
Validates the full OIDC flow on the model: that state values are atomically consumed (preventing replay), that stale rows are cleaned up, that authorization URLs include the right parameters, that ID tokens are properly verified (signature, nonce, email_verified), and that user matching is case-insensitive and whitespace-tolerant. The IdP is fully stubbed so tests run without network access.&lt;br /&gt;
&lt;br /&gt;
* '''.consume_recent_by_state!''' — Verifies the matching row is destroyed on consumption, expired or missing rows raise &amp;lt;code&amp;gt;RecordNotFound&amp;lt;/code&amp;gt;, and replay attempts fail because the row has already been deleted.&lt;br /&gt;
* '''.delete_stale''' — Confirms rows older than the validity window are deleted while fresh rows are preserved.&lt;br /&gt;
* '''Probabilistic cleanup on create''' — Confirms &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; is enqueued only when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the configured threshold.&lt;br /&gt;
* '''.authorization_uri_for!''' — Creates an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row with the username, returns a properly-formed authorization URI, falls back to default scopes when none are configured, and rejects duplicate state values via the unique index.&lt;br /&gt;
* '''#verified_email_from_code!''' — Returns the email when &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; is true, raises &amp;lt;code&amp;gt;AuthenticationError&amp;lt;/code&amp;gt; when the claim is absent or false, and raises &amp;lt;code&amp;gt;InvalidToken&amp;lt;/code&amp;gt; when the nonce doesn't match.&lt;br /&gt;
* '''#authenticate_user!''' — Matches users by username and email with case-insensitive, whitespace-tolerant comparison; raises &amp;lt;code&amp;gt;AuthenticationError&amp;lt;/code&amp;gt; when either field doesn't match or the email is blank.&lt;br /&gt;
* '''.new_client''' — Builds an &amp;lt;code&amp;gt;OpenIDConnect::Client&amp;lt;/code&amp;gt; with the correct credentials and discovery endpoints.&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
Covers YAML loading and validation of the provider config. Verifies ERB env var interpolation, memoization with explicit reload, defensive parsing of malformed inputs, the production-vs-non-production validation behavior (raise vs. warn), scope normalization, and that secrets never leak through &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
* '''.providers''' — Loads providers from YAML with ERB interpolation, memoizes results until &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt;, and gracefully handles malformed input (empty file, null providers, non-Hash structures, YAML aliases) by returning an empty hash. Skips invalid providers with a warning.&lt;br /&gt;
* '''Production behavior''' — Raises &amp;lt;code&amp;gt;InvalidConfiguration&amp;lt;/code&amp;gt; in production (rather than skipping with a warning) when providers are missing required keys or the YAML structure is invalid.&lt;br /&gt;
* '''.find''' — Returns the provider config by key, raises &amp;lt;code&amp;gt;ProviderNotFound&amp;lt;/code&amp;gt; for unknown keys.&lt;br /&gt;
* '''.public_list''' — Returns only id and name per provider, never secrets or endpoints.&lt;br /&gt;
* '''.scopes_for''' — Parses whitespace-, comma-, and mixed-delimited scope strings; falls back to default scopes when missing or nil.&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
Verifies the shared session token method used by both password and OIDC login: that the JWT contains the expected user attributes, has the correct expiry, and that signature tampering is detected on decode.&lt;br /&gt;
&lt;br /&gt;
* '''#generate_jwt''' — Encodes the expected user attributes (id, name, full_name, role, institution_id, exp), defaults to a 24-hour expiry, and rejects tampered tokens on decode.&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
End-to-end request specs covering the three OIDC endpoints. Validates the happy path, all documented error responses (400/401/404/502), the generic 401 policy that hides which specific check failed, and Rack::Attack rate limiting on the write endpoints.&lt;br /&gt;
&lt;br /&gt;
* '''GET /auth/providers''' — Returns the provider list with id and name only, with no secrets leaked.&lt;br /&gt;
* '''POST /auth/client-select''' — Returns an authorization URL on the happy path; returns 400 for missing params, 404 for unknown providers, and 502 when discovery fails.&lt;br /&gt;
* '''POST /auth/callback — happy path''' — Exchanges a valid code and state for a session JWT.&lt;br /&gt;
* '''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;''' — Returns the same generic 401 for all failure modes (no matching user, username/email mismatch, invalid or expired state, token verification failure, deleted provider) to avoid information leakage.&lt;br /&gt;
* '''POST /auth/callback — other errors''' — Returns 400 for missing params and 502 when discovery fails.&lt;br /&gt;
* '''Rate limiting (Rack::Attack)''' — Confirms requests succeed within the configured limit on each endpoint, return 429 with a &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt; header when exceeded, and that throttling is per-IP (one IP hitting the limit does not affect others).&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
Verifies the SSO modal behavior: that the SSO button only appears when providers are configured, the modal opens with the username input and provider dropdown, the form gates submission until both fields are filled, and that successful submits redirect to the IdP's authorization URL. Axios is mocked to keep tests offline.&lt;br /&gt;
&lt;br /&gt;
* '''OidcModal Component''' — Renders nothing when the providers response is empty or fails; renders the SSO button and opens a modal with the provider dropdown when providers are returned; disables submit until both username and provider are filled; posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; and redirects the browser to the returned authorization URL on success (no redirect on failure).&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
Verifies the callback page that handles the redirect back from the identity provider: that it posts the code and state to the backend on mount, processes the success response (storing the JWT, dispatching auth state, navigating to the dashboard), and degrades gracefully on missing params, IdP errors, or backend failures.&lt;br /&gt;
&lt;br /&gt;
* '''OidcCallback Component''' — Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the JWT and dispatches auth state on success, redirects to the dashboard, displays an error alert and redirects to login on failure, handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when params are missing, and shows a &amp;quot;Completing login...&amp;quot; message while in flight.&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the [https://youtu.be/ES6VWZxkj_w| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168164</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168164"/>
		<updated>2026-04-28T22:20:02Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Tests (74 total) */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Provider configuration is validated at boot via config/initializers/oidc.rb. In production, a provider with any missing required key raises OidcConfig::InvalidConfiguration, which prevents the application from starting with a misconfigured OIDC provider. In all other environments (development, test), the invalid provider is skipped with a warning logged so that local development and CI are not blocked when OIDC credentials are not configured.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institutions, some of which may want OIDC and others which may not and there is no institution context pre-login at this time, we felt that probably falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each identity provider is defined as a YAML configuration block with its own credentials, issuer, and scopes. &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt; operate on whatever provider key is passed in, with no &amp;lt;code&amp;gt;if provider == &amp;quot;google&amp;quot;&amp;lt;/code&amp;gt; branching. Adding a new institution's SSO requires only a new YAML entry and environment variables, no code changes anywhere.&lt;br /&gt;
** [https://github.com/johnmweisz/reimplementation-back-end/blob/9bc24c3ca0a13188899b3b72ec3208afcdd8a21b/app/controllers/oidc_login_controller.rb#L34 Source]&lt;br /&gt;
&lt;br /&gt;
* '''Singleton''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: provider configuration is memoized as class-level state and cleared via &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt;, ensuring YAML is parsed once per process. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing additional safety since necessary methods are not available to a new instance anyway.  AI recommended we keep it this way.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic Garbage Collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage (10%) of new request creations rather than scheduled. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests (74 total) ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
Validates the full OIDC flow on the model: that state values are atomically consumed (preventing replay), that stale rows are cleaned up, that authorization URLs include the right parameters, that ID tokens are properly verified (signature, nonce, email_verified), and that user matching is case-insensitive and whitespace-tolerant. The IdP is fully stubbed so tests run without network access.&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
* Raises a uniqueness error when two rows have the same state&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
* Raises InvalidToken when the token's nonce doesn't match (tampered/replayed token)&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
Covers YAML loading and validation of the provider config. Verifies ERB env var interpolation, memoization with explicit reload, defensive parsing of malformed inputs, the production-vs-non-production validation behavior (raise vs. warn), scope normalization, and that secrets never leak through &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
Verifies the shared session token method used by both password and OIDC login: that the JWT contains the expected user attributes, has the correct expiry, and that signature tampering is detected on decode.&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
End-to-end request specs covering the three OIDC endpoints. Validates the happy path, all documented error responses (400/401/404/502), the generic 401 policy that hides which specific check failed, and Rack::Attack rate limiting on the write endpoints.&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''Rate limiting (Rack::Attack)'''&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 5 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 6th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: hitting the limit on one IP does not affect a different IP&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 10 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 11th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
Verifies the SSO modal behavior: that the SSO button only appears when providers are configured, the modal opens with the username input and provider dropdown, the form gates submission until both fields are filled, and that successful submits redirect to the IdP's authorization URL. Axios is mocked to keep tests offline.&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
Verifies the callback page that handles the redirect back from the identity provider: that it posts the code and state to the backend on mount, processes the success response (storing the JWT, dispatching auth state, navigating to the dashboard), and degrades gracefully on missing params, IdP errors, or backend failures.&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the [https://youtu.be/ES6VWZxkj_w| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168106</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168106"/>
		<updated>2026-04-27T14:17:21Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design Patterns */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Provider configuration is validated at boot via config/initializers/oidc.rb. In production, a provider with any missing required key raises OidcConfig::InvalidConfiguration, which prevents the application from starting with a misconfigured OIDC provider. In all other environments (development, test), the invalid provider is skipped with a warning logged so that local development and CI are not blocked when OIDC credentials are not configured.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institutions, some of which may want OIDC and others which may not and there is no institution context pre-login at this time, we felt that probably falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each identity provider is defined as a YAML configuration block with its own credentials, issuer, and scopes. &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt; operate on whatever provider key is passed in, with no &amp;lt;code&amp;gt;if provider == &amp;quot;google&amp;quot;&amp;lt;/code&amp;gt; branching. Adding a new institution's SSO requires only a new YAML entry and environment variables, no code changes anywhere.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: provider configuration is memoized as class-level state and cleared via &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt;, ensuring YAML is parsed once per process. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing additional safety since necessary methods are not available to a new instance anyway.  AI recommended we keep it this way.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic Garbage Collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage (10%) of new request creations rather than scheduled. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests (74 total) ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
* .authorization_uri_for! Raises a uniqueness error when two rows have the same state&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
* #verified_email_from_code! Raises InvalidToken when the token's nonce doesn't match (tampered/replayed token)&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''Rate limiting (Rack::Attack)'''&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 5 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 6th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: hitting the limit on one IP does not affect a different IP&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 10 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 11th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the [https://youtu.be/ES6VWZxkj_w| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168105</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168105"/>
		<updated>2026-04-27T14:16:31Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design Patterns */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Provider configuration is validated at boot via config/initializers/oidc.rb. In production, a provider with any missing required key raises OidcConfig::InvalidConfiguration, which prevents the application from starting with a misconfigured OIDC provider. In all other environments (development, test), the invalid provider is skipped with a warning logged so that local development and CI are not blocked when OIDC credentials are not configured.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institutions, some of which may want OIDC and others which may not and there is no institution context pre-login at this time, we felt that probably falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each identity provider is defined as a YAML configuration block with its own credentials, issuer, and scopes. &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt; operate on whatever provider key is passed in, with no &amp;lt;code&amp;gt;if provider == &amp;quot;google&amp;quot;&amp;lt;/code&amp;gt; branching. Adding a new institution's SSO requires only a new YAML entry and environment variables, no code changes anywhere.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: provider configuration is memoized as class-level state and cleared via &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt;, ensuring YAML is parsed once per process. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing additional safety (since necessary methods are not available to a new instance.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic Garbage Collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage (10%) of new request creations rather than scheduled. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests (74 total) ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
* .authorization_uri_for! Raises a uniqueness error when two rows have the same state&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
* #verified_email_from_code! Raises InvalidToken when the token's nonce doesn't match (tampered/replayed token)&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''Rate limiting (Rack::Attack)'''&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 5 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 6th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: hitting the limit on one IP does not affect a different IP&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 10 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 11th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the [https://youtu.be/ES6VWZxkj_w| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168094</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168094"/>
		<updated>2026-04-26T17:11:28Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design Patterns */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Provider configuration is validated at boot via config/initializers/oidc.rb. In production, a provider with any missing required key raises OidcConfig::InvalidConfiguration, which prevents the application from starting with a misconfigured OIDC provider. In all other environments (development, test), the invalid provider is skipped with a warning logged so that local development and CI are not blocked when OIDC credentials are not configured.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institutions, some of which may want OIDC and others which may not and there is no institution context pre-login at this time, we felt that probably falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each identity provider is defined as a YAML configuration block with its own credentials, issuer, and scopes. &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt; operate on whatever provider key is passed in, with no &amp;lt;code&amp;gt;if provider == &amp;quot;google&amp;quot;&amp;lt;/code&amp;gt; branching. Adding a new institution's SSO requires only a new YAML entry and environment variables, no code changes anywhere.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: provider configuration is memoized as class-level state and cleared via &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt;, ensuring YAML is parsed once per process. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing additional safety.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic Garbage Collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage (10%) of new request creations rather than scheduled. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests (74 total) ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
* .authorization_uri_for! Raises a uniqueness error when two rows have the same state&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
* #verified_email_from_code! Raises InvalidToken when the token's nonce doesn't match (tampered/replayed token)&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''Rate limiting (Rack::Attack)'''&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 5 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 6th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: hitting the limit on one IP does not affect a different IP&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 10 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 11th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the [https://youtu.be/ES6VWZxkj_w| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168093</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168093"/>
		<updated>2026-04-26T17:09:34Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design Patterns */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Provider configuration is validated at boot via config/initializers/oidc.rb. In production, a provider with any missing required key raises OidcConfig::InvalidConfiguration, which prevents the application from starting with a misconfigured OIDC provider. In all other environments (development, test), the invalid provider is skipped with a warning logged so that local development and CI are not blocked when OIDC credentials are not configured.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institutions, some of which may want OIDC and others which may not and there is no institution context pre-login at this time, we felt that probably falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each identity provider is defined as a YAML configuration block with its own credentials, issuer, and scopes. &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt; operate on whatever provider key is passed in, with no &amp;lt;code&amp;gt;if provider == &amp;quot;google&amp;quot;&amp;lt;/code&amp;gt; branching. Adding a new institution's SSO requires only a new YAML entry and environment variables — no code changes anywhere.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: provider configuration is memoized as class-level state and cleared via &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt;, ensuring YAML is parsed once per process. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing additional safety.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic Garbage Collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage (10%) of new request creations rather than scheduled. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests (74 total) ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
* .authorization_uri_for! Raises a uniqueness error when two rows have the same state&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
* #verified_email_from_code! Raises InvalidToken when the token's nonce doesn't match (tampered/replayed token)&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''Rate limiting (Rack::Attack)'''&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 5 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 6th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: hitting the limit on one IP does not affect a different IP&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 10 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 11th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the [https://youtu.be/ES6VWZxkj_w| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168092</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168092"/>
		<updated>2026-04-26T17:06:29Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design Patterns */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Provider configuration is validated at boot via config/initializers/oidc.rb. In production, a provider with any missing required key raises OidcConfig::InvalidConfiguration, which prevents the application from starting with a misconfigured OIDC provider. In all other environments (development, test), the invalid provider is skipped with a warning logged so that local development and CI are not blocked when OIDC credentials are not configured.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institutions, some of which may want OIDC and others which may not and there is no institution context pre-login at this time, we felt that probably falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each identity provider is defined as a YAML configuration block with its own credentials, issuer, and scopes. &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt; operate on whatever provider key is passed in, with no &amp;lt;code&amp;gt;if provider == &amp;quot;google&amp;quot;&amp;lt;/code&amp;gt; branching. Adding a new institution's SSO requires only a new YAML entry and environment variables — no code changes anywhere.&lt;br /&gt;
&lt;br /&gt;
* '''Command''' — CleanupStaleOidcRequestsJob: The work of deleting stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows is encapsulated as a background job that can be enqueued, retried, or deferred by Active Job. The job itself is a single line (&amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;). This separates the decision of when to clean up (probabilistic, at row creation time) from the act of cleaning up (the job), and means cleanup can be safely moved to a scheduler later with no logic changes.&lt;br /&gt;
&lt;br /&gt;
* Provider configuration is memoized as class-level state in &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; and cleared via &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt;, ensuring YAML is parsed once per process.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests (74 total) ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
* .authorization_uri_for! Raises a uniqueness error when two rows have the same state&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
* #verified_email_from_code! Raises InvalidToken when the token's nonce doesn't match (tampered/replayed token)&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''Rate limiting (Rack::Attack)'''&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 5 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 6th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: hitting the limit on one IP does not affect a different IP&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 10 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 11th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the [https://youtu.be/ES6VWZxkj_w| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168088</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168088"/>
		<updated>2026-04-26T13:34:37Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Demo Video */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Provider configuration is validated at boot via config/initializers/oidc.rb. In production, a provider with any missing required key raises OidcConfig::InvalidConfiguration, which prevents the application from starting with a misconfigured OIDC provider. In all other environments (development, test), the invalid provider is skipped with a warning logged so that local development and CI are not blocked when OIDC credentials are not configured.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institutions, some of which may want OIDC and others which may not and there is no institution context pre-login at this time, we felt that probably falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each identity provider is defined as a YAML configuration block with its own credentials, issuer, and scopes. &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt; operate on whatever provider key is passed in, with no &amp;lt;code&amp;gt;if provider == &amp;quot;google&amp;quot;&amp;lt;/code&amp;gt; branching. Adding a new institution's SSO requires only a new YAML entry and environment variables — no code changes anywhere.&lt;br /&gt;
&lt;br /&gt;
* '''Facade''' — OidcLoginController: The controller exposes three simple, clean endpoints (providers, client_select, callback) while hiding the underlying complexity: YAML parsing, PKCE generation, database row management, provider discovery, token exchange, JWKS verification, and user matching all happen inside &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
* '''Chain of Responsibility''' — callback error handling: The callback action processes a sequence of steps -&amp;gt; consume state, exchange code, verify token, match user. Each can fail in a different way.&lt;br /&gt;
&lt;br /&gt;
* '''Command''' — CleanupStaleOidcRequestsJob: The work of deleting stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows is encapsulated as a background job that can be enqueued, retried, or deferred by Active Job. The job itself is a single line (&amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;). This separates the decision of when to clean up (probabilistic, at row creation time) from the act of cleaning up (the job), and means cleanup can be safely moved to a scheduler later with no logic changes.&lt;br /&gt;
&lt;br /&gt;
* '''Null Object (graceful degradation)''' — OidcModal: When &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; fails or returns an empty list, &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; renders nothing at all. The login page behaves exactly as if OIDC were never configured, and the standard username/password form is completely unaffected. This is cleaner than showing a loading spinner or error state, because from the user's perspective there is simply no SSO option instead of a broken one.&lt;br /&gt;
&lt;br /&gt;
* Provider configuration is memoized as class-level state in &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; and cleared via &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt;, ensuring YAML is parsed once per process.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests (74 total) ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
* .authorization_uri_for! Raises a uniqueness error when two rows have the same state&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
* #verified_email_from_code! Raises InvalidToken when the token's nonce doesn't match (tampered/replayed token)&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''Rate limiting (Rack::Attack)'''&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 5 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: 6th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: hitting the limit on one IP does not affect a different IP&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 10 requests from one IP all succeed (within limit)&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: 11th request from same IP gets 429 with &amp;quot;Rate limit exceeded&amp;quot;&lt;br /&gt;
* &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled response includes a Retry-After header&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the [https://youtu.be/ES6VWZxkj_w| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168083</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168083"/>
		<updated>2026-04-25T21:12:00Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Login */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institutions, some of which may want OIDC and others which may not and there is no institution context pre-login at this time, we felt that probably falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168082</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168082"/>
		<updated>2026-04-25T21:10:56Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Login */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institution, some of which may want OIDC and others which may not, we felt that probably falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168081</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168081"/>
		<updated>2026-04-25T21:09:42Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design Requirements */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Login ===&lt;br /&gt;
At one point it was suggested that OIDC completely replace password login if configured.  We considered this idea, however, given this feature must support multiple institutions and that decision is generally made at the user level either via a nullable password or an oidc flag, we felt that falls under account management and is beyond the scope of this assignment.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168080</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168080"/>
		<updated>2026-04-25T21:04:12Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Logout */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. The OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168079</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168079"/>
		<updated>2026-04-25T21:01:49Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Security */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is required and must be true.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168078</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168078"/>
		<updated>2026-04-25T21:00:54Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design Patterns */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168077</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168077"/>
		<updated>2026-04-25T21:00:37Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design Patterns */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168071</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168071"/>
		<updated>2026-04-24T19:08:20Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Backend */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168070</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168070"/>
		<updated>2026-04-24T19:05:46Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Planning */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb     — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
=== Story 15: Backend — Rate Limiting on OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints rate-limited at the application layer, '''so that''' abusive clients cannot fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table, hammer the IdP discovery endpoint, or brute-force callback state values.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add the &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem to the Gemfile.&lt;br /&gt;
* Configure throttles in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;: throttled per IP (e.g. 10 requests per minute) to prevent table-fill and discovery-spam attacks.&lt;br /&gt;
** &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;: throttled per IP (e.g. 20 requests per minute) to slow brute-force state guessing.&lt;br /&gt;
* Throttled requests return HTTP 429 with a JSON body matching the existing error response shape (&amp;lt;code&amp;gt;{ error: &amp;quot;Too many requests&amp;quot; }&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use the Rails cache (&amp;lt;code&amp;gt;Rails.cache&amp;lt;/code&amp;gt;) as the backing store so no additional infrastructure is required.&lt;br /&gt;
* Document the rationale in the initializer with a brief comment, including the note that gateway-level rate limiting (nginx, Cloudflare) should be added in front for defense in depth in production.&lt;br /&gt;
* Add request specs verifying:&lt;br /&gt;
** A burst of requests above the threshold returns 429.&lt;br /&gt;
** Requests under the threshold succeed normally.&lt;br /&gt;
** Different IPs are throttled independently.&lt;br /&gt;
* Verify existing endpoints (login, etc.) are not affected by the new rules.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168069</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168069"/>
		<updated>2026-04-24T19:02:45Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Library Choice */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== New Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb     — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168068</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168068"/>
		<updated>2026-04-24T19:02:07Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Library Choice */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
=== openid_connect ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
=== rack-attack ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; gem ([https://github.com/rack/rack-attack github.com/rack/rack-attack]) was added to provide rate limiting on the OIDC endpoints. Without it, an attacker could spam &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; to fill the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table or repeatedly probe &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; to attempt token guessing.&lt;br /&gt;
&lt;br /&gt;
* '''Throttle abuse before it reaches the application:''' Rack-level middleware rejects abusive clients before any controller code runs, protecting both the database and the IdP discovery endpoints from being hammered.&lt;br /&gt;
* '''Per-IP throttling on OIDC endpoints:''' &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.&lt;br /&gt;
* '''Lightweight and well-established:''' Single gem, no external dependencies (uses Rails cache for storage), maintained by the Rack team, used by tens of thousands of production Rails apps.&lt;br /&gt;
* '''Configuration in code:''' Rules live in &amp;lt;code&amp;gt;config/initializers/rack_attack.rb&amp;lt;/code&amp;gt; and are version-controlled alongside the rest of the auth configuration, rather than living in a gateway or load balancer config.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is that rate limiting at the application layer is best-effort — a sufficiently distributed attack can still overwhelm the app. For production deployment, a gateway-level rate limit (e.g. nginx, Cloudflare) should be added in front of &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; as defense in depth. &amp;lt;code&amp;gt;rack-attack&amp;lt;/code&amp;gt; is documented as the application-layer baseline, not the only line of defense.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb     — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168067</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168067"/>
		<updated>2026-04-24T19:00:14Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb     — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168066</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168066"/>
		<updated>2026-04-24T18:57:32Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Tests */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb     — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb     — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168065</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168065"/>
		<updated>2026-04-24T18:52:48Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* File Diffs */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb     — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Tests ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/expertiza/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_request_spec.rb spec/models/oidc_request_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests and preserves the row&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''Probabilistic cleanup on create'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold&lt;br /&gt;
* Does not enqueue when rand falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns provider authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email when email_verified is true&lt;br /&gt;
* Raises AuthenticationError when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Matches user when DB stores values with leading or trailing whitespace&lt;br /&gt;
* Raises AuthenticationError when email is blank&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither username nor email match&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/expertiza/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''.providers'''&lt;br /&gt;
* Loads providers from YAML and evaluates ERB env vars&lt;br /&gt;
* Memoizes results until reload! is called&lt;br /&gt;
* Skips providers missing required keys and warns&lt;br /&gt;
* Returns an empty hash when no providers key exists&lt;br /&gt;
* Returns an empty hash when YAML is empty&lt;br /&gt;
* Returns an empty hash when providers key is null&lt;br /&gt;
* Returns an empty hash when the top-level YAML is not a Hash&lt;br /&gt;
* Returns an empty hash when the providers value is not a Hash&lt;br /&gt;
* Supports YAML aliases in provider definitions&lt;br /&gt;
&lt;br /&gt;
'''Production behavior'''&lt;br /&gt;
* Raises InvalidConfiguration when a provider is missing required keys&lt;br /&gt;
* Raises InvalidConfiguration when the top-level YAML is not a Hash&lt;br /&gt;
* Raises InvalidConfiguration when the providers value is not a Hash&lt;br /&gt;
&lt;br /&gt;
'''.find'''&lt;br /&gt;
* Returns a provider config by key&lt;br /&gt;
* Raises ProviderNotFound for unknown provider keys&lt;br /&gt;
&lt;br /&gt;
'''.public_list'''&lt;br /&gt;
* Returns only id and name for each provider, never secrets&lt;br /&gt;
&lt;br /&gt;
'''.scopes_for'''&lt;br /&gt;
* Parses whitespace-delimited scope strings&lt;br /&gt;
* Parses comma-delimited scope strings&lt;br /&gt;
* Parses mixed comma and whitespace delimiters&lt;br /&gt;
* Falls back to default scopes when scopes is nil&lt;br /&gt;
* Falls back to default scopes when scopes key is absent&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/expertiza/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''#generate_jwt'''&lt;br /&gt;
* Encodes the user attributes (id, name, full_name, role, institution_id, exp) into a JWT&lt;br /&gt;
* Defaults to 24 hour expiry&lt;br /&gt;
* Raises an error when the token signature is invalid (tampered token rejected)&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/expertiza/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb spec/requests/oidc_login_spec.rb] ====&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — happy path'''&lt;br /&gt;
* Exchanges valid code and state for a session JWT&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — generic 401 &amp;quot;Authentication failed&amp;quot;'''&lt;br /&gt;
* When no user matches the username and email&lt;br /&gt;
* When email matches but username does not&lt;br /&gt;
* When state is invalid or expired&lt;br /&gt;
* When token verification fails&lt;br /&gt;
* When the stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback — other errors'''&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) ===&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/expertiza/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.test.tsx src/components/Modals/OidcModal.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders nothing when GET /auth/providers returns an empty array&lt;br /&gt;
* Renders nothing when GET /auth/providers fails&lt;br /&gt;
* Renders SSO button when providers are returned&lt;br /&gt;
* Opens the modal when the SSO button is pressed&lt;br /&gt;
* Populates the provider dropdown with configured providers&lt;br /&gt;
* Disables submit until both username and provider are provided&lt;br /&gt;
* Posts provider id and username to /auth/client-select on submit&lt;br /&gt;
* Redirects the browser to the returned authorization URL on success&lt;br /&gt;
* Does not redirect when client-select fails&lt;br /&gt;
&lt;br /&gt;
==== [https://github.com/expertiza/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx] ====&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to POST /auth/callback on mount&lt;br /&gt;
* Stores session JWT and dispatches auth state on success&lt;br /&gt;
* Redirects to dashboard on successful login&lt;br /&gt;
* Displays error alert and redirects to login on backend failure&lt;br /&gt;
* Handles IdP error query parameter without calling the backend&lt;br /&gt;
* Redirects to login when code or state query parameters are missing&lt;br /&gt;
* Shows &amp;quot;Completing login...&amp;quot; message while request is in flight&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168044</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168044"/>
		<updated>2026-04-24T00:15:00Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Planning */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
Application code ([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335]):&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
RSpec tests:&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Atomic state consumption and replay prevention, probabilistic cleanup enqueuing, authorization URL construction with username, ID token verification with strict email_verified check, case-insensitive user matching with whitespace normalization, client construction&lt;br /&gt;
 spec/models/oidc_config_spec.rb      — YAML loading and ERB interpolation, memoization and reload, required key validation (warn in dev, raise InvalidConfiguration in production), scope normalization, public_list secrets exclusion, ProviderNotFound lookup&lt;br /&gt;
 spec/models/user_spec.rb             — &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; payload structure, default and custom expiry, rejection of tampered tokens&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering the happy path, 400 for missing params, 404 for unknown provider, 502 for discovery failures, and a generic 401 &amp;quot;Authentication failed&amp;quot; for all callback failure modes (invalid state, token verification failure, username/email mismatch, deleted provider)&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
Application code ([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172]):&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
Vitest tests:&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO button conditional rendering, modal form with username and provider dropdown, redirect to authorization URL on submit&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Token exchange on mount, success flow (JWT storage, auth dispatch, dashboard redirect), error flows (backend failure, IdP error param, missing code/state)&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/9 Backend project board]&lt;br /&gt;
* [https://github.com/users/johnmweisz/projects/8 Frontend project board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* In production, raise &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to block startup with misconfigured providers; in other environments, skip invalid providers with a warning so local development and CI are not blocked.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, not null, unique, indexed), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; (not null), &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; (not null), and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Expose a &amp;lt;code&amp;gt;delete_stale&amp;lt;/code&amp;gt; class method that deletes rows older than the validity window.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and stale deletion.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is not explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive with whitespace trimmed on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — SSO Modal with Username and Provider Selection ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display an SSO button when providers are returned.&lt;br /&gt;
* On button press, open a modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown populated with the configured providers.&lt;br /&gt;
* Hide or disable the submit action until both username and provider are provided.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, form validation, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' submitting the SSO form to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On submit, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a scheduled job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On &amp;lt;code&amp;gt;after_create&amp;lt;/code&amp;gt; of &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt;, enqueue the job with a 10% probability (&amp;lt;code&amp;gt;CLEANUP_PROBABILITY&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use a &amp;lt;code&amp;gt;VALIDITY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add tests verifying stale rows are deleted, fresh rows are preserved, and the job is enqueued at the expected probability.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Request specs for the three OIDC endpoints covering happy paths and all documented error responses (400, 401, 404, 502).&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; handling, and PKCE code verifier flow.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
* Model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly. Many of these tests are added incrementally alongside each feature story; this story captures the consolidated coverage expectation and gap analysis.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Renders nothing on empty or failed providers response.&lt;br /&gt;
** Renders SSO button when providers are returned.&lt;br /&gt;
** Opens the modal when the SSO button is pressed.&lt;br /&gt;
** Populates the provider dropdown with configured providers.&lt;br /&gt;
** Requires both username and provider before submit is enabled.&lt;br /&gt;
** Includes provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload.&lt;br /&gt;
** Redirects the browser to the returned authorization URL on success.&lt;br /&gt;
** Does not redirect on failure.&lt;br /&gt;
* Component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
** Stores the session JWT and dispatches auth state on success.&lt;br /&gt;
** Redirects to the dashboard on success.&lt;br /&gt;
** Displays an error alert and redirects to login on backend failure.&lt;br /&gt;
** Handles the IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend.&lt;br /&gt;
** Redirects to login when code or state are missing.&lt;br /&gt;
** Shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168043</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168043"/>
		<updated>2026-04-24T00:12:09Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* File Diffs */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Pull Requests ===&lt;br /&gt;
&lt;br /&gt;
* Backend: [https://github.com/expertiza/reimplementation-back-end/pull/335 reimplementation-back-end#335]&lt;br /&gt;
* Frontend: [https://github.com/expertiza/reimplementation-front-end/pull/172 reimplementation-front-end#172]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
Application code ([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335]):&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                        — YAML config loader with validation (strict in production), scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;&lt;br /&gt;
 config/oidc_providers.yml                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb             — Migration for oidc_requests table&lt;br /&gt;
&lt;br /&gt;
RSpec tests:&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Atomic state consumption and replay prevention, probabilistic cleanup enqueuing, authorization URL construction with username, ID token verification with strict email_verified check, case-insensitive user matching with whitespace normalization, client construction&lt;br /&gt;
 spec/models/oidc_config_spec.rb      — YAML loading and ERB interpolation, memoization and reload, required key validation (warn in dev, raise InvalidConfiguration in production), scope normalization, public_list secrets exclusion, ProviderNotFound lookup&lt;br /&gt;
 spec/models/user_spec.rb             — &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; payload structure, default and custom expiry, rejection of tampered tokens&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering the happy path, 400 for missing params, 404 for unknown provider, 502 for discovery failures, and a generic 401 &amp;quot;Authentication failed&amp;quot; for all callback failure modes (invalid state, token verification failure, username/email mismatch, deleted provider)&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
Application code ([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172]):&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx         — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx     — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx          — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                 — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
Vitest tests:&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO button conditional rendering, modal form with username and provider dropdown, redirect to authorization URL on submit&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Token exchange on mount, success flow (JWT storage, auth dispatch, dashboard redirect), error flows (backend failure, IdP error param, missing code/state)&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168042</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168042"/>
		<updated>2026-04-24T00:10:15Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Provider Configuration (OidcConfig) */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used; non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168041</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168041"/>
		<updated>2026-04-24T00:07:28Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design Patterns */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request. Ruby's &amp;lt;code&amp;gt;Singleton&amp;lt;/code&amp;gt; module was not used because it would require changing every call site from &amp;lt;code&amp;gt;OidcConfig.find(...)&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;OidcConfig.instance.find(...)&amp;lt;/code&amp;gt; without providing any additional safety — the class-level approach achieves the same guarantees with cleaner call syntax.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168040</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168040"/>
		<updated>2026-04-24T00:06:32Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design Patterns */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
&lt;br /&gt;
* '''Strategy pattern''' — Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
* '''Singleton pattern''' — &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; is effectively a singleton: all methods are class-level, provider configuration is memoized in a class-level variable, and &amp;lt;code&amp;gt;reload!&amp;lt;/code&amp;gt; resets that state. This ensures a single source of truth for provider configuration and avoids repeated YAML parsing per request.&lt;br /&gt;
&lt;br /&gt;
* '''Template Method pattern''' — The OIDC login flow (fetch providers → build authorization URL → exchange code → verify token → match user) follows a fixed sequence of steps that is identical for every provider. Only the per-provider configuration values vary; the orchestrating methods in &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;OidcLoginController&amp;lt;/code&amp;gt; are shared across all providers.&lt;br /&gt;
&lt;br /&gt;
* '''Service Object pattern''' — &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; encapsulates the full OIDC flow (state generation, PKCE construction, token exchange, ID token verification, user matching) as cohesive methods on a single ActiveRecord model rather than spreading logic across the controller. The controller stays thin and delegates all protocol work to the model.&lt;br /&gt;
&lt;br /&gt;
* '''Atomic Consume (compensating database pattern)''' — &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; finds, locks, and destroys the matching row within a single transaction, preventing replay attacks and race conditions where two concurrent callbacks could both claim the same state.&lt;br /&gt;
&lt;br /&gt;
* '''Probabilistic garbage collection''' — Stale &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; rows are cleaned up on a small percentage of new request creations rather than via a scheduled job. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168039</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168039"/>
		<updated>2026-04-24T00:05:31Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present when accessed. In production, invalid configuration raises &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately in production.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt; to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim must be explicitly &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; — the same method used by the existing password login. Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification or matching failures (invalid state, replayed state, token verification failure, unverified email, no matching user, unknown provider) to avoid leaking which specific check failed.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, selects a provider, and clicks &amp;quot;Continue with SSO&amp;quot;, the form posts to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with both the provider id and username. On success, the browser is redirected to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || not null, unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables. Stale rows are cleaned up probabilistically on new request creation (10% chance to enqueue &amp;lt;code&amp;gt;CleanupStaleOidcRequestsJob&amp;lt;/code&amp;gt;), avoiding the need for a dedicated scheduler.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are handled based on environment: in production, startup fails with &amp;lt;code&amp;gt;OidcConfig::InvalidConfiguration&amp;lt;/code&amp;gt; to prevent running with misconfigured providers; in other environments, the invalid provider is skipped with a warning so local development and CI are not blocked. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168038</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168038"/>
		<updated>2026-04-23T22:27:34Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Account Matching */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza; multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user and compare against username entry to ensure unique match. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168037</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168037"/>
		<updated>2026-04-23T22:20:03Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Frontend */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user and compare against username entry to ensure unique match. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|1000px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|1000px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168036</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168036"/>
		<updated>2026-04-23T22:19:40Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Backend */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id and username. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, username, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user and compare against username entry to ensure unique match. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|500px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|500px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168035</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168035"/>
		<updated>2026-04-23T14:35:58Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Feature Requirements */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Design Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|500px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|500px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168034</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168034"/>
		<updated>2026-04-23T14:34:32Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Demo */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Feature Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|500px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|500px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo Video ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168033</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168033"/>
		<updated>2026-04-23T14:34:21Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Setup */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Feature Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|500px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|500px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== NCSU Google Provider Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168032</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168032"/>
		<updated>2026-04-23T14:33:19Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Feature Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|500px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|500px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&lt;br /&gt;
You can view the feature in action as well as edge cases, tests, and swagger by watching the  [https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168031</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168031"/>
		<updated>2026-04-23T14:32:06Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Feature Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|500px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|500px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&lt;br /&gt;
[https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
== Setup ==&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168030</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168030"/>
		<updated>2026-04-23T14:31:42Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Configuration */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Feature Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|500px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|500px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&lt;br /&gt;
[https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168029</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168029"/>
		<updated>2026-04-23T14:31:12Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Requirements */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Feature Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|500px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|500px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&lt;br /&gt;
[https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168028</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168028"/>
		<updated>2026-04-22T17:52:48Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Demo */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|500px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|500px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&lt;br /&gt;
[https://drive.google.com/file/d/1I8UrZVbHRGVYDu4LCYoYVtCCm946jCZp/view?usp=sharing| Demo Video]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168027</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168027"/>
		<updated>2026-04-22T16:21:49Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Frontend */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png|500px|Login Page with SSO Button]]&lt;br /&gt;
[[File:SSOLoginModal.png|500px|SSO Login Modal]]&lt;br /&gt;
&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:OIDC_Flow_Updated.png&amp;diff=168026</id>
		<title>File:OIDC Flow Updated.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:OIDC_Flow_Updated.png&amp;diff=168026"/>
		<updated>2026-04-22T16:14:39Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: Jweisz uploaded a new version of File:OIDC Flow Updated.png&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Summary ==&lt;br /&gt;
Shows the design flow of oidc login&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168025</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168025"/>
		<updated>2026-04-22T16:11:58Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC_Flow_Updated.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:OIDC_Flow_Updated.png&amp;diff=168024</id>
		<title>File:OIDC Flow Updated.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:OIDC_Flow_Updated.png&amp;diff=168024"/>
		<updated>2026-04-22T16:11:38Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: Shows the design flow of oidc login&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Summary ==&lt;br /&gt;
Shows the design flow of oidc login&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168023</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168023"/>
		<updated>2026-04-22T16:10:15Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC Provider-2026-04-06-223511.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168022</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168022"/>
		<updated>2026-04-22T16:09:44Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Design */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC Provider-2026-04-06-223511.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
sequenceDiagram&lt;br /&gt;
    participant User&lt;br /&gt;
    participant FE as Frontend&lt;br /&gt;
    participant BE as Backend&lt;br /&gt;
    participant DB as Database&lt;br /&gt;
    participant IdP as OIDC Provider&lt;br /&gt;
&lt;br /&gt;
    Note over BE,IdP: 0. App Boot&lt;br /&gt;
&lt;br /&gt;
    BE-&amp;gt;&amp;gt;BE: Load provider configs&amp;lt;br/&amp;gt;(YAML + ENV secrets)&amp;lt;br/&amp;gt;Validate required keys,&amp;lt;br/&amp;gt;skip invalid with warning&lt;br /&gt;
&lt;br /&gt;
    Note over User,IdP: 1. Fetch Available Providers&lt;br /&gt;
&lt;br /&gt;
    User-&amp;gt;&amp;gt;FE: Navigates to login page&lt;br /&gt;
    FE-&amp;gt;&amp;gt;BE: GET /auth/providers&lt;br /&gt;
    BE--&amp;gt;&amp;gt;FE: 200 [{ id, name }]&lt;br /&gt;
    FE-&amp;gt;&amp;gt;FE: Render provider dropdown&amp;lt;br/&amp;gt;and username input&lt;br /&gt;
&lt;br /&gt;
    Note over User,IdP: 2. Initiate Login&lt;br /&gt;
&lt;br /&gt;
    User-&amp;gt;&amp;gt;FE: Enters username,&amp;lt;br/&amp;gt;selects provider&lt;br /&gt;
    FE-&amp;gt;&amp;gt;BE: POST /auth/client-select&amp;lt;br/&amp;gt;{ provider, username }&lt;br /&gt;
    BE-&amp;gt;&amp;gt;IdP: GET /.well-known/openid-configuration&lt;br /&gt;
    IdP--&amp;gt;&amp;gt;BE: Discovery document&amp;lt;br/&amp;gt;(endpoints, JWKS keys)&lt;br /&gt;
    BE-&amp;gt;&amp;gt;BE: Generate state, nonce &amp;amp; PKCE&lt;br /&gt;
    BE-&amp;gt;&amp;gt;DB: INSERT oidc_requests&amp;lt;br/&amp;gt;{ state, nonce, code_verifier,&amp;lt;br/&amp;gt;provider, username, created_at }&lt;br /&gt;
    BE-&amp;gt;&amp;gt;BE: Probabilistic cleanup&amp;lt;br/&amp;gt;(10% chance: enqueue&amp;lt;br/&amp;gt;CleanupStaleOidcRequestsJob)&lt;br /&gt;
    BE--&amp;gt;&amp;gt;FE: 200 { redirect_uri }&lt;br /&gt;
&lt;br /&gt;
    Note over User,IdP: 3. Authorization Flow&lt;br /&gt;
&lt;br /&gt;
    FE-&amp;gt;&amp;gt;User: Redirect to redirect_uri&lt;br /&gt;
    User-&amp;gt;&amp;gt;IdP: GET /authorize?&amp;lt;br/&amp;gt;client_id, redirect_uri,&amp;lt;br/&amp;gt;scope, state, nonce,&amp;lt;br/&amp;gt;code_challenge&lt;br /&gt;
    IdP-&amp;gt;&amp;gt;User: Show consent screen&lt;br /&gt;
    User-&amp;gt;&amp;gt;IdP: Grant consent&lt;br /&gt;
&lt;br /&gt;
    Note over User,IdP: 4. Callback &amp;amp; Token Exchange&lt;br /&gt;
&lt;br /&gt;
    IdP--&amp;gt;&amp;gt;FE: 302 → /auth/callback?code=abc&amp;amp;state=xyz&lt;br /&gt;
    FE-&amp;gt;&amp;gt;FE: Extract code &amp;amp; state&amp;lt;br/&amp;gt;from query params&lt;br /&gt;
    FE-&amp;gt;&amp;gt;BE: POST /auth/callback&amp;lt;br/&amp;gt;{ code, state }&lt;br /&gt;
    BE-&amp;gt;&amp;gt;DB: SELECT FOR UPDATE oidc_requests&amp;lt;br/&amp;gt;WHERE state = ? AND created_at &amp;gt; 5.min.ago&amp;lt;br/&amp;gt;then DELETE (atomic, prevents replay)&lt;br /&gt;
    BE-&amp;gt;&amp;gt;IdP: GET /.well-known/openid-configuration&lt;br /&gt;
    IdP--&amp;gt;&amp;gt;BE: Discovery document&lt;br /&gt;
    BE-&amp;gt;&amp;gt;IdP: POST /token&amp;lt;br/&amp;gt;{ code, code_verifier,&amp;lt;br/&amp;gt;client_id, client_secret }&lt;br /&gt;
    IdP--&amp;gt;&amp;gt;BE: { access_token, id_token }&lt;br /&gt;
    BE-&amp;gt;&amp;gt;BE: Verify id_token&amp;lt;br/&amp;gt;(signature, issuer,&amp;lt;br/&amp;gt;audience, nonce,&amp;lt;br/&amp;gt;email_verified = true)&lt;br /&gt;
    BE-&amp;gt;&amp;gt;BE: Match user by username + email&amp;lt;br/&amp;gt;(case-insensitive, trimmed)&amp;lt;br/&amp;gt;Issue session JWT&lt;br /&gt;
&lt;br /&gt;
    Note over User,IdP: 5. Login Complete&lt;br /&gt;
&lt;br /&gt;
    BE--&amp;gt;&amp;gt;FE: 200 { token }&lt;br /&gt;
    FE-&amp;gt;&amp;gt;FE: Store token,&amp;lt;br/&amp;gt;update auth state&lt;br /&gt;
    FE--&amp;gt;&amp;gt;User: Redirect to dashboard&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168021</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168021"/>
		<updated>2026-04-22T16:07:12Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Account Matching */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. The provider must validate email with the &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and if it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC Provider-2026-04-06-223511.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168020</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168020"/>
		<updated>2026-04-22T16:04:12Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Purpose */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application. Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. Supporting these standard protocols at sites where they are in use is more secure for the application, provides a familiar and streamlined login experience, and frees Expertiza from managing credentials for users whose institution already does so. This design introduces [https://openid.net/developers/how-connect-works/ OIDC] login as an additional authentication option alongside the existing username and password login. Both methods will continue to be supported, allowing users to choose their preferred approach.&lt;br /&gt;
&lt;br /&gt;
== Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. If the provider includes an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC Provider-2026-04-06-223511.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168019</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168019"/>
		<updated>2026-04-21T21:45:20Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Backend (RSpec) Tests */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application.  Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. It is more secure for applications to use the standard approach at sites where they are in use, and it also frees Expertiza from managing passwords, and thus removes the risk of compromise. By integrating [https://openid.net/developers/how-connect-works/ OIDC] login, users can authenticate using their existing university credentials, providing a familiar and streamlined login experience. Traditional username and password login will continue to be supported alongside OIDC, allowing users to choose their preferred authentication method.&lt;br /&gt;
&lt;br /&gt;
== Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. If the provider includes an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC Provider-2026-04-06-223511.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168018</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168018"/>
		<updated>2026-04-21T21:43:02Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* Planning */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application.  Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. It is more secure for applications to use the standard approach at sites where they are in use, and it also frees Expertiza from managing passwords, and thus removes the risk of compromise. By integrating [https://openid.net/developers/how-connect-works/ OIDC] login, users can authenticate using their existing university credentials, providing a familiar and streamlined login experience. Traditional username and password login will continue to be supported alongside OIDC, allowing users to choose their preferred authentication method.&lt;br /&gt;
&lt;br /&gt;
== Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. If the provider includes an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC Provider-2026-04-06-223511.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 '''TODO''' spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168017</id>
		<title>CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168017"/>
		<updated>2026-04-21T21:42:06Z</updated>

		<summary type="html">&lt;p&gt;Jweisz: /* File Diffs */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Purpose ==&lt;br /&gt;
Expertiza currently authenticates users with its own login page, implemented by the Expertiza application.  Expertiza has been used at many campuses, however, and each has their own SSO (single signon) protocol that students and staff use to log into other applications. It is more secure for applications to use the standard approach at sites where they are in use, and it also frees Expertiza from managing passwords, and thus removes the risk of compromise. By integrating [https://openid.net/developers/how-connect-works/ OIDC] login, users can authenticate using their existing university credentials, providing a familiar and streamlined login experience. Traditional username and password login will continue to be supported alongside OIDC, allowing users to choose their preferred authentication method.&lt;br /&gt;
&lt;br /&gt;
== Requirements ==&lt;br /&gt;
=== Authentication Flow ===&lt;br /&gt;
Users enter their Expertiza username on the login page and select a provider from a dropdown. The frontend posts the username and provider to the backend, which returns an authorization URL. The user is redirected to the school's OIDC provider, authenticates, and is redirected back to the frontend callback. The callback posts the authorization code and state to the backend to complete login. The frontend fetches available providers from the backend via &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders them dynamically in a dropdown.&lt;br /&gt;
&lt;br /&gt;
=== Session Management ===&lt;br /&gt;
Issue and maintain a local application session (JWT) after successful OIDC authentication, using the same &amp;lt;code&amp;gt;JsonWebToken&amp;lt;/code&amp;gt; class and payload structure as the existing password login. Refresh token grant flow will not be considered at this time (since session is managed by the application).&lt;br /&gt;
&lt;br /&gt;
=== Account Matching ===&lt;br /&gt;
Match the authenticated user by both the Expertiza username (provided before login) and the verified email claim from the ID token. Username is required because email addresses are not unique in Expertiza — multiple accounts may share the same email. Matching is case-insensitive on both fields. If the provider includes an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim and it is not &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;, the login is rejected. No dedicated account linking table or just-in-time account creation will be built at this time. If no matching local account is found, a generic authentication error is returned.&lt;br /&gt;
&lt;br /&gt;
=== Configuration ===&lt;br /&gt;
* OIDC provider configurations (display name, scopes, endpoints) are defined in a YAML config file (&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Client credentials (client ID, client secret) are stored in environment variables and injected via ERB.&lt;br /&gt;
* Providers must support OIDC discovery;&lt;br /&gt;
** Their endpoints and JWKS keys are fetched automatically from the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document.&lt;br /&gt;
* The system supports multiple OIDC provider configurations simultaneously.&lt;br /&gt;
* Providers with missing required configuration are skipped at boot with a warning logged.&lt;br /&gt;
&lt;br /&gt;
You can find more details about how to set up the Google OIDC Provider at [https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup Google OIDC Setup]&lt;br /&gt;
&lt;br /&gt;
=== State Management ===&lt;br /&gt;
OIDC state, nonce, PKCE code verifier, username, and provider key are stored server-side in an &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; database table (via ActiveRecord) rather than in session cookies. This avoids cross-origin cookie issues between the separate frontend and backend. Rows are expired after 5 minutes and consumed (deleted) on successful callback. A probabilistic inline cleanup removes stale rows on new request creation to keep the table bounded without requiring a scheduled job. Note that many OIDC libraries (including &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt;) use cookies to track state; due to SameSite restrictions on cross-origin requests, this approach leads to instability with a separated frontend and backend and should be avoided.&lt;br /&gt;
&lt;br /&gt;
=== Logout ===&lt;br /&gt;
Logout will not be impacted. Expertiza remains the authentication server; the OIDC flow is only used to verify the user's identity with an external provider at login time. Once the user is authenticated, Expertiza issues its own session JWT, and all subsequent requests use that local session. The IdP session is independent of the Expertiza session, so logging out of Expertiza (destroying the local session) does not affect the user's session at the IdP, and logging out of the IdP does not affect the user's Expertiza session. RP-initiated logout (ending the IdP session as part of application logout) is out of scope.&lt;br /&gt;
&lt;br /&gt;
=== Error Handling ===&lt;br /&gt;
All callback failure modes (invalid state, expired state, replayed state, no matching user, mismatched username or email, failed token verification, unverified email, unknown provider) return a generic &amp;quot;Authentication failed&amp;quot; response with HTTP 401 to avoid leaking information about which specific check failed. Provider communication failures (discovery or token endpoint unreachable) return HTTP 502. Missing required parameters return HTTP 400. Unknown providers on &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt; return HTTP 404.&lt;br /&gt;
&lt;br /&gt;
=== Security ===&lt;br /&gt;
Use the Authorization Code flow with the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; Ruby gem (by nov). Validate the ID token signature and claims via JWKS keys from the provider's discovery document. Enforce a &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; parameter to prevent CSRF and a &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; to prevent replay attacks. State rows are atomically consumed in a database transaction with row-level locking to prevent race conditions on replay. PKCE (code verifier and code challenge) is always included in the authorization request and token exchange; providers that support it will enforce it, and providers that do not will ignore the extra parameters. The backend is a confidential client and always authenticates with both client secret and PKCE. The &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is checked when present.&lt;br /&gt;
&lt;br /&gt;
=== Testing ===&lt;br /&gt;
Backend and frontend are tested independently. Backend request and model specs stub the identity provider's discovery, token, and JWKS endpoints to exercise the full controller and model logic (state management, token exchange, ID token verification, user matching, case-insensitive lookup, email verification) without external dependencies. Frontend component tests mock axios calls to verify rendering, dropdown behavior, username input, callback handling, and error display. End-to-end testing across both systems with a live identity provider is not planned at this time, as it would require standing up a mock IdP server (e.g. Keycloak or mock-oauth2-server), which is beyond the scope of the existing test infrastructure. The full OIDC login flow will be manually verified against Google's OIDC provider in a local development environment and demonstrated as needed.&lt;br /&gt;
&lt;br /&gt;
== Design ==&lt;br /&gt;
&lt;br /&gt;
[[File:OIDC Provider-2026-04-06-223511.png|1000px]]&lt;br /&gt;
&lt;br /&gt;
=== Backend ===&lt;br /&gt;
* '''Boot (Step 0):''' Load provider configurations from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class validates that all required keys are present at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;. For providers with &amp;lt;code&amp;gt;discovery: true&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; document is fetched using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem to resolve the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Discovery results are not aggressively cached to allow for key rotation; on signature verification failure, keys are re-fetched and verification is retried once.&lt;br /&gt;
* '''Provider List (Step 1):''' Expose a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;. No secrets or endpoint details are included in this response.&lt;br /&gt;
* '''Client Select (Step 2):''' Expose a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider id. Generate a cryptographically random state and nonce via &amp;lt;code&amp;gt;SecureRandom.hex(32)&amp;lt;/code&amp;gt;, and a PKCE code verifier via &amp;lt;code&amp;gt;SecureRandom.urlsafe_base64(64)&amp;lt;/code&amp;gt; with a SHA256 code challenge. Insert a row into the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table containing the state, nonce, code verifier, provider id, and creation timestamp. Construct the authorization URL using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem's &amp;lt;code&amp;gt;authorization_uri&amp;lt;/code&amp;gt; method and return it to the frontend.&lt;br /&gt;
* '''Callback (Step 4):''' Expose a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint (and a temporary &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt; for direct IdP redirect during backend-only testing) that accepts the authorization code and state. Look up the matching &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; row by state, rejecting the request if no row is found or if the row is older than 5 minutes. Delete the row to prevent reuse. Using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem, exchange the authorization code for tokens via &amp;lt;code&amp;gt;access_token!&amp;lt;/code&amp;gt; with the stored code verifier. Decode the ID token using &amp;lt;code&amp;gt;OpenIDConnect::ResponseObject::IdToken.decode&amp;lt;/code&amp;gt; against the provider's JWKS keys, and verify the issuer, client_id, and nonce via &amp;lt;code&amp;gt;id_token.verify!&amp;lt;/code&amp;gt;. Extract the user's email from the ID token claims and look up a matching local user. If a match is found, issue a session JWT using the same &amp;lt;code&amp;gt;JsonWebToken.encode&amp;lt;/code&amp;gt; method and payload structure as the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; action. If no match is found, return a 404 error indicating no local account exists for that email.&lt;br /&gt;
&lt;br /&gt;
=== Frontend ===&lt;br /&gt;
* '''Login Page (Step 1):'''&lt;br /&gt;
** On page load, the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;&lt;br /&gt;
*** If the request fails or returns empty, the component renders nothing, and the standard login form remains available and unaffected. No loading state is shown to avoid visual disruption when no providers are configured.&lt;br /&gt;
*** If providers are found, an SSO login button is displayed.&lt;br /&gt;
** Once the SSO Button is clicked, a modal displays with a username field and a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider.&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
* '''Initiate Login (Step 2):''' Once the user enters their username, provider and clicks Continue with SSO, the form does a &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the selected provider id. On success, it redirects the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;. The user then authenticates with the identity provider and is redirected back to the frontend callback route.&lt;br /&gt;
* '''Callback (Step 4):''' The &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; page component handles the redirect back from the identity provider at &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. It extracts the authorization code and state from the query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;s them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;. If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; parameter instead of a code (e.g. the user denied consent), the error is displayed without calling the backend, and the user is redirected to the login page.&lt;br /&gt;
* '''Login Complete (Step 5):''' On a successful callback response, we store the session JWT via &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, update the Redux auth state via &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, persist the session to localStorage, and redirect the user to the dashboard. This mirrors the existing password login flow exactly. On failure, display an error alert and redirect to the login page.&lt;br /&gt;
* The existing username and password login flow remains unchanged and fully functional.&lt;br /&gt;
&lt;br /&gt;
=== Design Patterns ===&lt;br /&gt;
The implementation uses the '''Strategy pattern''' for provider configuration. Each OIDC provider is defined declaratively in YAML with its own credentials, scopes, and endpoints, while the controller logic remains provider-agnostic. Adding a new identity provider requires only a new configuration block and environment variables, with no code changes.&lt;br /&gt;
&lt;br /&gt;
=== Schema (OidcRequest) ===&lt;br /&gt;
The &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; table stores temporary OIDC login state. Each row represents a single in-progress login attempt and is deleted after use or expiry.&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Column !! Type !! Constraints !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; || bigint || primary key || Row identifier&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; || string || unique, indexed || CSRF protection; used to look up the request on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt; || string || not null || Replay attack prevention; verified against the ID token claim&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt; || string || not null || PKCE secret; sent to the token endpoint to prove the same party initiated the flow&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; || string || not null || Which OIDC provider config to use on callback&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; || string || not null || Expertiza username entered before login; used alongside the verified email claim to match an existing user (emails are not unique)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; || datetime || not null || Used to expire rows older than 5 minutes&lt;br /&gt;
|}&lt;br /&gt;
No foreign keys or associations to other tables.&lt;br /&gt;
&lt;br /&gt;
=== Provider Configuration (OidcConfig) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; model loads OIDC identity provider definitions from &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; at boot. Each provider is defined as a keyed entry under &amp;lt;code&amp;gt;providers:&amp;lt;/code&amp;gt;. The top-level key is the provider id used in API requests and stored in the &amp;lt;code&amp;gt;oidc_requests.provider&amp;lt;/code&amp;gt; column. Client credentials are injected from environment variables via ERB to keep secrets out of version control.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Key !! Required !! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| ''provider key'' (e.g. &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;) || yes || Unique identifier for this provider. Sent by the frontend in &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; and stored on the &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row. Use a short, URL-safe slug.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt; || yes || Human-readable name shown to users in the login dropdown (e.g. &amp;quot;Google NCSU&amp;quot;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt; || yes || The OIDC issuer URL (e.g. &amp;lt;code&amp;gt;https://accounts.google.com&amp;lt;/code&amp;gt;). Used to fetch the &amp;lt;code&amp;gt;.well-known/openid-configuration&amp;lt;/code&amp;gt; discovery document, which provides the authorization endpoint, token endpoint, userinfo endpoint, and JWKS keys. Must match the &amp;lt;code&amp;gt;iss&amp;lt;/code&amp;gt; claim in ID tokens issued by this provider.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt; || yes || OAuth client identifier obtained when registering the application with the identity provider. Sent in the authorization request and token exchange. Typically injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt; || yes || OAuth client secret obtained during registration. Used to authenticate the backend to the token endpoint. Must be kept secret — always injected via &amp;lt;code&amp;gt;&amp;lt;%= ENV['PROVIDER_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;, never hardcoded.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt; || yes || The URL the identity provider redirects to after authentication. Must exactly match the value registered with the provider (scheme, host, port, and path). Should point to the frontend callback route (e.g. &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;).&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; || no || Space-separated OIDC scopes requested from the provider. Defaults to &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt; if omitted. The &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; scope is required to receive an ID token; &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; is required for account matching.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; exposes &amp;lt;code&amp;gt;find(provider_key)&amp;lt;/code&amp;gt; for internal lookups and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; for the frontend-facing &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; response (which only includes id and display name, never secrets or endpoints). Providers missing any required key are skipped at boot with a warning logged, and they do not appear in &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt;. Discovery is always used — non-discovery providers are not supported. The configuration is validated once at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
providers:&lt;br /&gt;
  google-ncsu:&lt;br /&gt;
    display_name: Google NCSU&lt;br /&gt;
    issuer: https://accounts.google.com&lt;br /&gt;
    client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&lt;br /&gt;
    client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&lt;br /&gt;
    redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&lt;br /&gt;
    scopes: openid email profile&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Library Choice ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem (by nov, [https://github.com/nov/openid_connect github.com/nov/openid_connect]) was chosen over &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; for the following reasons:&lt;br /&gt;
&lt;br /&gt;
* '''No cookie/session dependency:''' &amp;lt;code&amp;gt;omniauth_openid_connect&amp;lt;/code&amp;gt; stores state and nonce in the server-side session via cookies. With a separate frontend and backend on different origins, session cookies are not reliably shared due to SameSite restrictions. Using &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; directly allows state management via the database instead.&lt;br /&gt;
* '''Explicit control:''' The gem provides building blocks (discovery, client construction, token exchange, ID token verification) without middleware magic. Each step in the OIDC flow is visible in the controller code.&lt;br /&gt;
* '''Lightweight:''' No OmniAuth middleware stack or Rack integration required. The gem handles the protocol; the application handles routing and state.&lt;br /&gt;
* '''Actively maintained:''' The gem is OpenID Foundation certified and used by 2,700+ projects on GitHub.&lt;br /&gt;
&lt;br /&gt;
The tradeoff is approximately 10 additional lines of code for state management (generating and storing state/nonce/PKCE in the &amp;lt;code&amp;gt;auth_requests&amp;lt;/code&amp;gt; table), which is minimal compared to the complexity of debugging cross-origin cookie issues.&lt;br /&gt;
&lt;br /&gt;
== File Diffs ==&lt;br /&gt;
&lt;br /&gt;
=== Backend (Rails) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 app/controllers/oidc_login_controller.rb                         — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 app/models/oidc_request.rb                                       — ActiveRecord model owning state/nonce/PKCE/username storage, OIDC flow, account matching, and probabilistic stale cleanup&lt;br /&gt;
 app/models/oidc_config.rb                                        — YAML config loader with validation, scope normalization, and public_list filtering&lt;br /&gt;
 app/models/user.rb                                               — Added &amp;lt;code&amp;gt;generate_jwt&amp;lt;/code&amp;gt; method shared with password login&lt;br /&gt;
 app/jobs/cleanup_stale_oidc_requests_job.rb                      — ActiveJob that calls &amp;lt;code&amp;gt;OidcRequest.delete_stale&amp;lt;/code&amp;gt;, enqueued probabilistically on request creation&lt;br /&gt;
 config/oidc_providers.yml                                        — Provider configuration (ERB for env var injection)&lt;br /&gt;
 config/initializers/oidc.rb                                      — Boot-time config validation&lt;br /&gt;
 config/routes.rb                                                 — New routes for the three OIDC endpoints&lt;br /&gt;
 db/migrate/*_create_oidc_requests.rb                             — Migration for oidc_requests table&lt;br /&gt;
 db/migrate/*_add_username_to_oidc_requests.rb                    — Migration adding username column for account matching&lt;br /&gt;
&lt;br /&gt;
=== Backend (RSpec) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-back-end/pull/335 PR #335])&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_request_spec.rb     — Model tests covering:&lt;br /&gt;
&lt;br /&gt;
'''.consume_recent_by_state!'''&lt;br /&gt;
* Returns and destroys a recent request matching state&lt;br /&gt;
* Raises RecordNotFound for unknown state&lt;br /&gt;
* Raises RecordNotFound for expired requests (and preserves the row)&lt;br /&gt;
* Prevents replay by destroying the row on consumption&lt;br /&gt;
&lt;br /&gt;
'''.delete_stale'''&lt;br /&gt;
* Deletes rows older than the validity window and preserves fresh rows&lt;br /&gt;
&lt;br /&gt;
'''after_create probabilistic cleanup'''&lt;br /&gt;
* Enqueues CleanupStaleOidcRequestsJob when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls under the threshold&lt;br /&gt;
* Does not enqueue when &amp;lt;code&amp;gt;rand&amp;lt;/code&amp;gt; falls above the threshold&lt;br /&gt;
&lt;br /&gt;
'''.authorization_uri_for!'''&lt;br /&gt;
* Creates an oidc_requests row with username and returns authorization URI&lt;br /&gt;
* Uses default scopes when provider scopes are missing&lt;br /&gt;
&lt;br /&gt;
'''#verified_email_from_code!'''&lt;br /&gt;
* Exchanges code, verifies token, and returns email&lt;br /&gt;
* Passes when email_verified claim is true&lt;br /&gt;
* Passes when email_verified claim is absent&lt;br /&gt;
* Raises AuthenticationError when email_verified is false&lt;br /&gt;
&lt;br /&gt;
'''#authenticate_user!'''&lt;br /&gt;
* Matches user by exact username and email&lt;br /&gt;
* Matches case-insensitively on username&lt;br /&gt;
* Matches case-insensitively on email&lt;br /&gt;
* Matches case-insensitively on both fields&lt;br /&gt;
* Raises AuthenticationError when email matches but username does not&lt;br /&gt;
* Raises AuthenticationError when username matches but email does not&lt;br /&gt;
* Raises AuthenticationError when neither matches&lt;br /&gt;
&lt;br /&gt;
'''.new_client'''&lt;br /&gt;
* Builds an OpenIDConnect::Client with provider credentials and discovery endpoints&lt;br /&gt;
&lt;br /&gt;
 spec/models/oidc_config_spec.rb     — Config loading, ERB env var interpolation, memoization and reload, missing key detection with warning log, YAML edge cases (empty file, null providers key, aliases), &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, &amp;lt;code&amp;gt;find&amp;lt;/code&amp;gt; lookup and ProviderNotFound, scope normalization (whitespace and comma-delimited, default fallback)&lt;br /&gt;
&lt;br /&gt;
 spec/requests/oidc_login_spec.rb     — Endpoint tests covering:&lt;br /&gt;
&lt;br /&gt;
'''GET /auth/providers'''&lt;br /&gt;
* Returns provider list with id and name only, no secrets leaked&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/client-select'''&lt;br /&gt;
* Returns authorization URL for a valid provider and username&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 404 for unknown provider&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
&lt;br /&gt;
'''POST /auth/callback'''&lt;br /&gt;
* Happy path: exchanges valid code and state for a session JWT&lt;br /&gt;
* Returns 400 when required parameters are missing&lt;br /&gt;
* Returns 502 when provider discovery fails&lt;br /&gt;
* Returns generic 401 &amp;quot;Authentication failed&amp;quot; for:&lt;br /&gt;
** No user matching the username and email&lt;br /&gt;
** Email matches but username does not&lt;br /&gt;
** Invalid or expired state&lt;br /&gt;
** Token verification failure&lt;br /&gt;
** Stored provider no longer exists in config&lt;br /&gt;
&lt;br /&gt;
 '''TODO''' spec/models/user_spec.rb           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.tsx            — Modal displaying the SSO button and provider dropdown with username input&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.tsx        — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 src/pages/Authentication/Login.tsx             — Existing login page with the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 src/App.tsx                                    — Added &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route&lt;br /&gt;
&lt;br /&gt;
=== Frontend (Vitest) Tests ===&lt;br /&gt;
([https://github.com/expertiza/reimplementation-front-end/pull/172 PR #172])&lt;br /&gt;
&lt;br /&gt;
 src/components/Modals/OidcModal.test.tsx        — SSO modal display and provider dropdown component tests&lt;br /&gt;
 src/pages/OidcCallback/OidcCallback.test.tsx    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO button when providers are configured, renders nothing when the providers response is empty or fails&lt;br /&gt;
* Displays the modal form with username input and provider dropdown when providers are returned&lt;br /&gt;
* Posts the provider id and username to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; on selection and redirects the browser to the returned authorization URL&lt;br /&gt;
&lt;br /&gt;
'''OidcCallback Component'''&lt;br /&gt;
* Posts code and state to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; on mount, stores the session JWT, dispatches auth state, and redirects to the dashboard on success&lt;br /&gt;
* Displays an error and redirects to login on backend failure, on IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query param (without calling the backend), and on missing code or state&lt;br /&gt;
&lt;br /&gt;
=== Routes ===&lt;br /&gt;
 GET  /auth/providers      → oidc_login#providers&lt;br /&gt;
 POST /auth/client-select  → oidc_login#client_select&lt;br /&gt;
 POST /auth/callback       → oidc_login#callback&lt;br /&gt;
 /auth/callback            → React OidcCallback component (frontend route)&lt;br /&gt;
&lt;br /&gt;
== Planning ==&lt;br /&gt;
&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/8 frontend board]&lt;br /&gt;
[https://github.com/users/johnmweisz/projects/9 backend board]&lt;br /&gt;
&lt;br /&gt;
=== Story 1: Backend — OIDC Provider Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' provider configurations loaded from a YAML file at boot, '''so that''' new OIDC providers can be added without code changes.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; with ERB support for injecting secrets from environment variables.&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; class that loads and validates the YAML, exposing methods to list providers, look up a provider by key, and normalize scopes.&lt;br /&gt;
* Define the config file path as a constant (&amp;lt;code&amp;gt;CONFIG_FILE&amp;lt;/code&amp;gt;) for clarity.&lt;br /&gt;
* Validate required keys: &amp;lt;code&amp;gt;display_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;issuer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client_secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;redirect_uri&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Skip providers with missing keys and log a warning rather than crashing the app.&lt;br /&gt;
* Validate configuration at boot via &amp;lt;code&amp;gt;config/initializers/oidc.rb&amp;lt;/code&amp;gt; so issues surface immediately on deploy.&lt;br /&gt;
* Add unit tests for config loading, validation, missing key detection, scope normalization, and &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion.&lt;br /&gt;
&lt;br /&gt;
=== Story 2: Backend — OIDC Requests Table ===&lt;br /&gt;
'''As a''' developer, '''I want''' a database-backed store for OIDC state, nonce, PKCE code verifier, and username, '''so that''' the backend can validate callbacks without relying on cookies.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Generate an ActiveRecord migration for &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with columns: &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; (string, indexed, unique), &amp;lt;code&amp;gt;nonce&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code_verifier&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Create the &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; model with a &amp;lt;code&amp;gt;recent&amp;lt;/code&amp;gt; scope for expiry filtering and a &amp;lt;code&amp;gt;consume_recent_by_state!&amp;lt;/code&amp;gt; method that atomically finds, locks, and destroys the row in a transaction to prevent replay.&lt;br /&gt;
* Probabilistically clean up stale rows inside &amp;lt;code&amp;gt;authorization_uri_for!&amp;lt;/code&amp;gt; (10% chance per call) to keep the table bounded without requiring a scheduled job.&lt;br /&gt;
* Add unit tests for creation, atomic consumption, expiry, replay prevention, and cleanup.&lt;br /&gt;
&lt;br /&gt;
=== Story 3: Backend — Provider List Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; endpoint, '''so that''' the login page can dynamically render provider options.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create a controller action that returns a JSON array of &amp;lt;code&amp;gt;{ id, name }&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;OidcConfig.public_list&amp;lt;/code&amp;gt;.&lt;br /&gt;
* No secrets or endpoint URLs are included in the response.&lt;br /&gt;
* Add a request spec covering the response format.&lt;br /&gt;
&lt;br /&gt;
=== Story 4: Backend — Client Select Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; endpoint that accepts a provider and username, and returns an authorization URL, '''so that''' the frontend can redirect the user to the identity provider.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;provider&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Look up the provider config and fetch the discovery document.&lt;br /&gt;
* Generate cryptographically random state, nonce, and PKCE code verifier and challenge.&lt;br /&gt;
* Insert a row into &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; with state, nonce, code_verifier, provider, and username.&lt;br /&gt;
* Construct and return the authorization URL with client_id, redirect_uri, scopes, state, nonce, and code_challenge.&lt;br /&gt;
* Return a 404 if the provider is unknown.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, unknown provider, and discovery failure.&lt;br /&gt;
&lt;br /&gt;
=== Story 5: Backend — Callback Endpoint ===&lt;br /&gt;
'''As a''' frontend developer, '''I want''' a &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; endpoint that exchanges the authorization code for tokens and returns a session, '''so that''' the user is logged in after completing the OIDC flow.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Accept required &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; params (return 400 on missing params).&lt;br /&gt;
* Atomically consume the matching &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt; row by state, rejecting if not found, expired, or already consumed.&lt;br /&gt;
* Exchange the code for tokens using the &amp;lt;code&amp;gt;openid_connect&amp;lt;/code&amp;gt; gem with the stored code_verifier.&lt;br /&gt;
* Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.&lt;br /&gt;
* Reject the login if an &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim is present and false.&lt;br /&gt;
* Match an existing user by username (from &amp;lt;code&amp;gt;oidc_requests&amp;lt;/code&amp;gt;) and email (from ID token), case-insensitive on both.&lt;br /&gt;
* On match: issue a session JWT via &amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt; and return &amp;lt;code&amp;gt;{ token }&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Return a generic 401 &amp;quot;Authentication failed&amp;quot; for all verification and matching failures to avoid information leakage.&lt;br /&gt;
* Return a 502 if provider discovery fails.&lt;br /&gt;
* Add request specs covering the happy path, missing params, invalid/expired state, replay, token verification failure, username/email mismatch, unverified email, and unknown provider.&lt;br /&gt;
&lt;br /&gt;
=== Story 6: Frontend — Provider Dropdown with Username Input on Login Page ===&lt;br /&gt;
'''As a''' user, '''I want''' to enter my username and select a provider on the login page, '''so that''' I can authenticate with my school credentials against the correct Expertiza account.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Create an &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component that calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; on mount.&lt;br /&gt;
* Display SSO Button when providers are configured properly&lt;br /&gt;
* On SSO Button press, renders the modal with a username text input and a &amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt; dropdown with a disabled &amp;quot;Sign in with...&amp;quot; default option.&lt;br /&gt;
* Hide the dropdown until the username input is non-empty.&lt;br /&gt;
* If the providers request fails or returns empty, render nothing (no error, no placeholder).&lt;br /&gt;
* Existing login form remains unchanged and fully functional.&lt;br /&gt;
* Add component tests for rendering with providers, username-gated dropdown visibility, and graceful fallback.&lt;br /&gt;
&lt;br /&gt;
=== Story 7: Frontend — Initiate OIDC Flow ===&lt;br /&gt;
'''As a''' user, '''I want''' selecting a provider from the dropdown to start the login flow, '''so that''' I am redirected to my school's login page.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* On selection change, &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/auth/client-select&amp;lt;/code&amp;gt; with the provider id and username.&lt;br /&gt;
* On success, redirect the browser to the returned authorization URL via &amp;lt;code&amp;gt;window.location.href&amp;lt;/code&amp;gt;.&lt;br /&gt;
* On failure, log the error to the console.&lt;br /&gt;
* Add component tests for the payload, redirect, and error handling.&lt;br /&gt;
&lt;br /&gt;
=== Story 8: Frontend — Callback Route and Login Completion ===&lt;br /&gt;
'''As a''' user, '''I want''' to be logged in automatically after authenticating with my school, '''so that''' I don't have to take any additional steps.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add a &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt; route in the React router pointing to the &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt; component.&lt;br /&gt;
* Extract &amp;lt;code&amp;gt;code&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;state&amp;lt;/code&amp;gt; from query parameters and &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; them to &amp;lt;code&amp;gt;/auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* If the query parameters contain an &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; param (e.g. user denied consent), display the error via the alert slice and redirect to login without calling the backend.&lt;br /&gt;
* On success: call &amp;lt;code&amp;gt;setAuthToken&amp;lt;/code&amp;gt;, persist session to localStorage, dispatch &amp;lt;code&amp;gt;authenticationActions.setAuthentication&amp;lt;/code&amp;gt;, and redirect to the dashboard — mirroring the existing password login flow.&lt;br /&gt;
* On failure: display an error message via the alert slice and redirect to the login page.&lt;br /&gt;
* Show a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Add component tests for success, provider error, and backend error scenarios.&lt;br /&gt;
&lt;br /&gt;
=== Story 9: Backend — Unified Session Response ===&lt;br /&gt;
'''As a''' developer, '''I want''' the session token generation shared by all login flows, '''so that''' the frontend can rely on a consistent response shape regardless of authentication method.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Extract the JWT payload construction and token issuance logic into a shared method on the &amp;lt;code&amp;gt;User&amp;lt;/code&amp;gt; model (&amp;lt;code&amp;gt;user.generate_jwt&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Update &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; to use the shared method without changing its external response shape.&lt;br /&gt;
* Use the shared method in &amp;lt;code&amp;gt;OidcLoginController#callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, custom expiry, and signature verification (tampered tokens rejected).&lt;br /&gt;
* Verify existing password login request specs still pass.&lt;br /&gt;
&lt;br /&gt;
=== Story 10: Frontend — Externalize Hardcoded Configuration ===&lt;br /&gt;
'''As a''' developer, '''I want''' the frontend API base URL moved to configuration, '''so that''' environment-specific settings can be changed without code modifications.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Move the API base URL (currently &amp;lt;code&amp;gt;http://localhost:3002&amp;lt;/code&amp;gt;) to an environment variable (e.g. &amp;lt;code&amp;gt;REACT_APP_API_URL&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Replace all hardcoded references in &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;Login&amp;lt;/code&amp;gt; components.&lt;br /&gt;
* Document the variable in the README.&lt;br /&gt;
* Ensure all existing tests continue to pass after the extraction.&lt;br /&gt;
&lt;br /&gt;
=== Story 11: Backend — Swagger Documentation for OIDC Endpoints ===&lt;br /&gt;
'''As a''' developer, '''I want''' the OIDC endpoints documented in Swagger, '''so that''' frontend developers and future contributors can understand the API contract without reading the source code.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add Swagger/OpenAPI annotations for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Document request parameters, response schemas (success and error shapes), and HTTP status codes for each endpoint.&lt;br /&gt;
* Include example request and response payloads.&lt;br /&gt;
* Verify the endpoints appear correctly in the generated Swagger UI.&lt;br /&gt;
&lt;br /&gt;
=== Story 12: Backend — Probabilistic Cleanup of Stale OIDC Requests ===&lt;br /&gt;
'''As a''' developer, '''I want''' stale OIDC request rows cleaned up automatically without a background job, '''so that''' the table does not grow unbounded from abandoned login attempts and no additional infrastructure is required.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* In &amp;lt;code&amp;gt;OidcRequest.authorization_uri_for!&amp;lt;/code&amp;gt;, run a DELETE for rows older than the expiry window with a 10% probability per call (&amp;lt;code&amp;gt;if rand &amp;lt; 0.1&amp;lt;/code&amp;gt;).&lt;br /&gt;
* Use an &amp;lt;code&amp;gt;EXPIRY_WINDOW&amp;lt;/code&amp;gt; constant so the cleanup threshold matches the consumption window.&lt;br /&gt;
* Add a test verifying that stale rows are eventually removed and fresh rows are preserved.&lt;br /&gt;
* Document the rationale in the model with a brief inline comment.&lt;br /&gt;
&lt;br /&gt;
=== Story 13: Backend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' RSpec coverage for the OIDC backend, '''so that''' I have confidence the endpoints, models, and security checks work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Add request specs for &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt;, and &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Stub the identity provider's discovery, token, and JWKS endpoints to avoid external calls in tests.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;client-select&amp;lt;/code&amp;gt;: happy path, missing params (400), unknown provider (404), discovery failure (502).&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; happy path: valid code and state exchanged for a session JWT, row consumed after use.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; generic 401 &amp;quot;Authentication failed&amp;quot; for: invalid or expired state, replayed state, no matching user, username/email mismatch, token verification failure (bad signature, issuer, audience, or nonce), unverified email, unknown provider on consumed row.&lt;br /&gt;
* Cover &amp;lt;code&amp;gt;callback&amp;lt;/code&amp;gt; missing params (400) and discovery failure (502).&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcRequest&amp;lt;/code&amp;gt; covering: atomic state consumption, replay prevention, expiry window, case-insensitive user matching, &amp;lt;code&amp;gt;email_verified&amp;lt;/code&amp;gt; claim handling, and PKCE code verifier sent to the token endpoint.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;OidcConfig&amp;lt;/code&amp;gt; covering: config loading, ERB interpolation, missing key detection, scope normalization, &amp;lt;code&amp;gt;public_list&amp;lt;/code&amp;gt; secrets exclusion, and unknown provider lookup.&lt;br /&gt;
* Add model specs for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default and custom expiry, and rejection of tampered tokens.&lt;br /&gt;
* Verify the existing &amp;lt;code&amp;gt;AuthenticationController#login&amp;lt;/code&amp;gt; specs still pass unchanged.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
=== Story 14: Frontend — Tests ===&lt;br /&gt;
'''As a''' developer, '''I want''' Vitest coverage for the OIDC frontend components, '''so that''' I have confidence the login flow and callback work correctly.&lt;br /&gt;
&lt;br /&gt;
'''Acceptance Criteria:'''&lt;br /&gt;
* Mock axios calls to avoid external requests in tests.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt;:&lt;br /&gt;
** renders nothing on empty or failed providers response&lt;br /&gt;
** renders SSO button when providers are returned&lt;br /&gt;
** displays modal when SSO button is pressed&lt;br /&gt;
** displays providers in dropdown when providers are returned, &lt;br /&gt;
** requires username and provider to both be filled out before SSO can be pressed&lt;br /&gt;
** includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload,&lt;br /&gt;
** redirects the browser to the returned authorization URL on success&lt;br /&gt;
** does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;:&lt;br /&gt;
** posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount&lt;br /&gt;
** stores session JWT and dispatches auth state on success&lt;br /&gt;
** redirects to dashboard on success&lt;br /&gt;
** displays error alert and redirects to login on backend failure&lt;br /&gt;
** handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend&lt;br /&gt;
** redirects to login when code or state are missing&lt;br /&gt;
** shows a &amp;quot;Completing login...&amp;quot; message while the token exchange is in progress.&lt;br /&gt;
* Verify the existing login page renders and functions correctly with and without the &amp;lt;code&amp;gt;OidcModal&amp;lt;/code&amp;gt; component.&lt;br /&gt;
&lt;br /&gt;
(Note that many of these tests may be done during development, so if anything, this story is about verification and completeness)&lt;br /&gt;
&lt;br /&gt;
== Demo ==&lt;br /&gt;
&amp;lt;gallery&amp;gt;&lt;br /&gt;
Image:LoginPageWithSSOButton.png | Login Page with SSO Button&lt;br /&gt;
Image:SSOLoginModal.png | SSO Login Modal&lt;br /&gt;
&amp;lt;/gallery&amp;gt;&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jweisz</name></author>
	</entry>
</feed>