<?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=Jvargas6</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=Jvargas6"/>
	<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Special:Contributions/Jvargas6"/>
	<updated>2026-05-22T09:55:49Z</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=168011</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=168011"/>
		<updated>2026-04-20T14:11:31Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Story 14: Frontend — 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.tsx src/components/Modals/OidcModal.tsx]            — Modal that displays SSO Button Component and Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx 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;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — SSO Modal display and Provider dropdown component tests&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]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO Button if SSO 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;
 GET /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>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168010</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=168010"/>
		<updated>2026-04-20T14:07:26Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Story 10: Frontend — Externalize Hardcoded Configuration */&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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.tsx src/components/Modals/OidcModal.tsx]            — Modal that displays SSO Button Component and Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx 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;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — SSO Modal display and Provider dropdown component tests&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]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO Button if SSO 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;
 GET /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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168009</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=168009"/>
		<updated>2026-04-20T14:06:55Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Story 6: Frontend — Provider Dropdown with Username Input on Login Page */&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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.tsx src/components/Modals/OidcModal.tsx]            — Modal that displays SSO Button Component and Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx 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;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — SSO Modal display and Provider dropdown component tests&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]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO Button if SSO 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;
 GET /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;OidcLogin&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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168008</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=168008"/>
		<updated>2026-04-20T14:05:29Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Frontend (React) */&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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.tsx src/components/Modals/OidcModal.tsx]            — Modal that displays SSO Button Component and Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx 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;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — SSO Modal display and Provider dropdown component tests&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]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO Button if SSO 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;
 GET /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;OidcLogin&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;
* Render 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;OidcLogin&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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168007</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=168007"/>
		<updated>2026-04-20T14:04:56Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Frontend (Vitest) 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.tsx src/components/Modals/OidcModal.tsx]            — Modal that displays SSO Button Component and Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx src/pages/Authentication/Login.tsx]              — Existing login page with the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — SSO Modal display and Provider dropdown component tests&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]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcModal Component'''&lt;br /&gt;
* Renders SSO Button if SSO 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;
 GET /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;OidcLogin&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;
* Render 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;OidcLogin&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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168006</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=168006"/>
		<updated>2026-04-20T14:00:51Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Frontend (React) */&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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/Modals/OidcModal.tsx src/components/Modals/OidcModal.tsx]            — Modal that displays SSO Button Component and Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx src/pages/Authentication/Login.tsx]              — Existing login page with the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.test.tsx src/components/OidcLogin/OidcLogin.test.tsx]     — Provider dropdown component tests&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcLogin Component'''&lt;br /&gt;
* Renders the username input and provider dropdown when providers are returned, hides the dropdown until username is entered, and renders nothing when the providers response is empty or fails&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;
 GET /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;OidcLogin&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;
* Render 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;OidcLogin&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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168005</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=168005"/>
		<updated>2026-04-20T13:48:06Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Frontend */&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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.tsx src/components/OidcLogin/OidcLogin.tsx]      — Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx src/pages/Authentication/Login.tsx]              — Existing login page with the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.test.tsx src/components/OidcLogin/OidcLogin.test.tsx]     — Provider dropdown component tests&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcLogin Component'''&lt;br /&gt;
* Renders the username input and provider dropdown when providers are returned, hides the dropdown until username is entered, and renders nothing when the providers response is empty or fails&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;
 GET /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;OidcLogin&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;
* Render 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;OidcLogin&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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168004</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=168004"/>
		<updated>2026-04-20T13:38:57Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Demo */&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):''' On page load, the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider below the existing username and password form. 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;
* '''Initiate Login (Step 2):''' When the user selects a provider from the dropdown, &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, redirect 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, 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.tsx src/components/OidcLogin/OidcLogin.tsx]      — Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx src/pages/Authentication/Login.tsx]              — Existing login page with the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.test.tsx src/components/OidcLogin/OidcLogin.test.tsx]     — Provider dropdown component tests&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcLogin Component'''&lt;br /&gt;
* Renders the username input and provider dropdown when providers are returned, hides the dropdown until username is entered, and renders nothing when the providers response is empty or fails&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;
 GET /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;OidcLogin&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;
* Render 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;OidcLogin&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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168003</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=168003"/>
		<updated>2026-04-20T13:37:59Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Demo */&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):''' On page load, the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider below the existing username and password form. 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;
* '''Initiate Login (Step 2):''' When the user selects a provider from the dropdown, &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, redirect 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, 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.tsx src/components/OidcLogin/OidcLogin.tsx]      — Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx src/pages/Authentication/Login.tsx]              — Existing login page with the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.test.tsx src/components/OidcLogin/OidcLogin.test.tsx]     — Provider dropdown component tests&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcLogin Component'''&lt;br /&gt;
* Renders the username input and provider dropdown when providers are returned, hides the dropdown until username is entered, and renders nothing when the providers response is empty or fails&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;
 GET /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;OidcLogin&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;
* Render 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;OidcLogin&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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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;
LoginPageWithSSOButton.png|Login Page with SSO Button&lt;br /&gt;
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>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=168002</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=168002"/>
		<updated>2026-04-20T13:33:22Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Demo */&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):''' On page load, the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider below the existing username and password form. 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;
* '''Initiate Login (Step 2):''' When the user selects a provider from the dropdown, &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, redirect 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, 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.tsx src/components/OidcLogin/OidcLogin.tsx]      — Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx src/pages/Authentication/Login.tsx]              — Existing login page with the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.test.tsx src/components/OidcLogin/OidcLogin.test.tsx]     — Provider dropdown component tests&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcLogin Component'''&lt;br /&gt;
* Renders the username input and provider dropdown when providers are returned, hides the dropdown until username is entered, and renders nothing when the providers response is empty or fails&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;
 GET /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;OidcLogin&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;
* Render 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;OidcLogin&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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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;
Login Page with SSO Button:&lt;br /&gt;
&lt;br /&gt;
[[File:LoginPageWithSSOButton.png]]&lt;br /&gt;
&lt;br /&gt;
SSO Login Modal:&lt;br /&gt;
&lt;br /&gt;
[[File:SSOLoginModal.png]]&lt;br /&gt;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:LoginPageWithSSOButton.png&amp;diff=168001</id>
		<title>File:LoginPageWithSSOButton.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:LoginPageWithSSOButton.png&amp;diff=168001"/>
		<updated>2026-04-20T13:31:41Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=File:SSOLoginModal.png&amp;diff=168000</id>
		<title>File:SSOLoginModal.png</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=File:SSOLoginModal.png&amp;diff=168000"/>
		<updated>2026-04-20T13:30:42Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=167999</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=167999"/>
		<updated>2026-04-20T13:27:15Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Configuration */&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):''' On page load, the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider below the existing username and password form. 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;
* '''Initiate Login (Step 2):''' When the user selects a provider from the dropdown, &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, redirect 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, 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.tsx src/components/OidcLogin/OidcLogin.tsx]      — Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx src/pages/Authentication/Login.tsx]              — Existing login page with the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.test.tsx src/components/OidcLogin/OidcLogin.test.tsx]     — Provider dropdown component tests&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcLogin Component'''&lt;br /&gt;
* Renders the username input and provider dropdown when providers are returned, hides the dropdown until username is entered, and renders nothing when the providers response is empty or fails&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;
 GET /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;OidcLogin&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;
* Render 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;OidcLogin&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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2618._Support_OIDC_Logins&amp;diff=167998</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=167998"/>
		<updated>2026-04-20T13:25:18Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Configuration */&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&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):''' On page load, the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component calls &amp;lt;code&amp;gt;GET /auth/providers&amp;lt;/code&amp;gt; and renders a dropdown (&amp;lt;code&amp;gt;Form.Select&amp;lt;/code&amp;gt;) for each configured provider below the existing username and password form. 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;
* '''Initiate Login (Step 2):''' When the user selects a provider from the dropdown, &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, redirect 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, 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/controllers/oidc_login_controller.rb app/controllers/oidc_login_controller.rb]  — Thin controller for providers, client_select, and callback actions with centralized error handling&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_request.rb app/models/oidc_request.rb]                — ActiveRecord model owning state/nonce/PKCE/username storage and the full OIDC flow&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/oidc_config.rb app/models/oidc_config.rb]                  — YAML config loader with validation and scope normalization&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/app/models/user.rb 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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/oidc_providers.yml config/oidc_providers.yml]                 — Provider configuration (ERB for env var injection)&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/initializers/oidc.rb config/initializers/oidc.rb]              — Boot-time config validation&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/config/routes.rb config/routes.rb]                                    — New routes for the three OIDC endpoints&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260407003623_create_oidc_requests.rb db/migrate/*_create_oidc_requests.rb]      — Migration for oidc_requests table&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/db/migrate/20260414000000_add_username_to_oidc_requests.rb 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/johnmweisz/reimplementation-back-end/tree/2618-oidc-login branch: 2618-oidc-login])&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]     — 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;
* Supports a custom recency window&lt;br /&gt;
* Prevents replay by destroying the row on consumption&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;
 [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/requests/oidc_login_spec.rb 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''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/user_spec.rb spec/models/user_spec.rb]           — Tests for &amp;lt;code&amp;gt;User#generate_jwt&amp;lt;/code&amp;gt; covering payload structure, default expiry, and signature verification&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-back-end/blob/2618-oidc-login/spec/models/oidc_config_spec.rb spec/models/oidc_config_spec.rb]   — Config loading, validation, missing keys, public_list secrets exclusion, provider lookup, scope normalization&lt;br /&gt;
&lt;br /&gt;
=== Frontend (React) ===&lt;br /&gt;
([https://github.com/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.tsx src/components/OidcLogin/OidcLogin.tsx]      — Provider dropdown component with username input&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.tsx src/pages/OidcCallback/OidcCallback.tsx]    — Callback page handling code exchange and auth state dispatch&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/Authentication/Login.tsx src/pages/Authentication/Login.tsx]              — Existing login page with the &amp;lt;code&amp;gt;OidcLogin&amp;lt;/code&amp;gt; component added below the password form&lt;br /&gt;
 [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/App.tsx 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/johnmweisz/reimplementation-front-end/tree/2618-oidc-login branch: 2618-oidc-login])&lt;br /&gt;
&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/components/OidcLogin/OidcLogin.test.tsx src/components/OidcLogin/OidcLogin.test.tsx]     — Provider dropdown component tests&lt;br /&gt;
 '''TODO''' [https://github.com/johnmweisz/reimplementation-front-end/blob/2618-oidc-login/src/pages/OidcCallback/OidcCallback.test.tsx src/pages/OidcCallback/OidcCallback.test.tsx]    — Callback page tests&lt;br /&gt;
&lt;br /&gt;
'''OidcLogin Component'''&lt;br /&gt;
* Renders the username input and provider dropdown when providers are returned, hides the dropdown until username is entered, and renders nothing when the providers response is empty or fails&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;
 GET /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;OidcLogin&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;
* Render 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;OidcLogin&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;OidcLogin&amp;lt;/code&amp;gt;: renders dropdown when providers are returned, renders nothing on empty or failed providers response, dropdown is hidden until the username input has a value, includes both provider id and username in the &amp;lt;code&amp;gt;POST /auth/client-select&amp;lt;/code&amp;gt; payload, redirects the browser to the returned authorization URL on success, does not redirect on failure.&lt;br /&gt;
* Add component tests for &amp;lt;code&amp;gt;OidcCallback&amp;lt;/code&amp;gt;: posts code and state to &amp;lt;code&amp;gt;POST /auth/callback&amp;lt;/code&amp;gt; on mount, stores session JWT and dispatches auth state on success, redirects to dashboard on success, displays error alert and redirects to login on backend failure, handles IdP &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt; query parameter without calling the backend, redirects to login when code or state are missing, 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;OidcLogin&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;
todo add screenshots of oidc login at each step&lt;/div&gt;</summary>
		<author><name>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup&amp;diff=167997</id>
		<title>Google OIDC Setup</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=Google_OIDC_Setup&amp;diff=167997"/>
		<updated>2026-04-20T13:21:45Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: Google OIDC Provider Setup&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= OIDC Setup (Google) for Expertiza Backend =&lt;br /&gt;
&lt;br /&gt;
This guide explains how to configure &amp;lt;code&amp;gt;Google Cloud Console&amp;lt;/code&amp;gt; so this Rails app can authenticate with OIDC and access a user's &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
'''Note:''' Current provider config is in &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; under &amp;lt;code&amp;gt;providers.google-ncsu&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== 1: Prerequisites ==&lt;br /&gt;
&lt;br /&gt;
* Google account with access to [https://console.cloud.google.com/ Google Cloud Console]&lt;br /&gt;
* The full stack to be running&lt;br /&gt;
** Vite/React Frontend&lt;br /&gt;
** Ruby on Rails API (This project)&lt;br /&gt;
** MySQL database&lt;br /&gt;
** Redis (currently used for caching, though not currently use for offloading temporary OIDC tokens)&lt;br /&gt;
* The redirect URI that matches the app config exactly&lt;br /&gt;
** Local example: &amp;lt;code&amp;gt;http://localhost:3000/auth/callback&amp;lt;/code&amp;gt;&lt;br /&gt;
** This value should come from &amp;lt;code&amp;gt;GOOG_REDIRECT_URI&amp;lt;/code&amp;gt; for the Rails API&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== 2: Create or select a Google Cloud project ==&lt;br /&gt;
&lt;br /&gt;
# Open [https://console.cloud.google.com/ Google Cloud Console].&lt;br /&gt;
# Create a [https://console.cloud.google.com/projectcreate new project] (or select an existing one).&lt;br /&gt;
# Make sure the selected project is the one you want for OIDC.&lt;br /&gt;
#* If you create a new project but had existing projects, you may need to select your new project.&lt;br /&gt;
#* Google will sometimes select the previous project you used rather than the newly created project.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== 3: Configure OAuth consent screen ==&lt;br /&gt;
&lt;br /&gt;
# Go to Explore and Enable APIs: [https://console.cloud.google.com/apis/dashboard APIs Dashboard]&lt;br /&gt;
# Click the '''OAuth consent screen''' in the side navigation.&lt;br /&gt;
# Click &amp;quot;Get Started&amp;quot; and fill in the required app details&lt;br /&gt;
#* App name&lt;br /&gt;
#** Example: NCSU SSO&lt;br /&gt;
#* Support email&lt;br /&gt;
#** For local dev use your email&lt;br /&gt;
#* Developer contact email&lt;br /&gt;
#** For local dev use your email&lt;br /&gt;
# Choose '''External''' (unless you've been instructed otherwise for test/production).&lt;br /&gt;
# Agree to the API terms and create.&lt;br /&gt;
&lt;br /&gt;
=== Configure Scopes ===&lt;br /&gt;
&lt;br /&gt;
# Select &amp;lt;code&amp;gt;Data Access&amp;lt;/code&amp;gt; in the left navigation.&lt;br /&gt;
# Click Add/Remove '''Scopes'''.&lt;br /&gt;
#* At a minimum we need to add:&lt;br /&gt;
#** &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt;&lt;br /&gt;
#** &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt;, displays as &amp;lt;code&amp;gt;.../auth/userinfo.email&amp;lt;/code&amp;gt;&lt;br /&gt;
#** &amp;lt;code&amp;gt;profile&amp;lt;/code&amp;gt;, displays as &amp;lt;code&amp;gt;.../auth/userinfo.profile&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Why not &amp;quot;email scope only&amp;quot; ====&lt;br /&gt;
&lt;br /&gt;
For OIDC login, Google should receive &amp;lt;code&amp;gt;openid email&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; gives access to the email claim.&lt;br /&gt;
* &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt; is required for an OIDC ID token.&lt;br /&gt;
&lt;br /&gt;
If you send only &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt; without &amp;lt;code&amp;gt;openid&amp;lt;/code&amp;gt;, OIDC token validation will fail.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== 4: Add test users (if app is in Testing mode) ==&lt;br /&gt;
&lt;br /&gt;
* Click &amp;lt;code&amp;gt;Audience&amp;lt;/code&amp;gt; in the side navigation.&lt;br /&gt;
* Go to the &amp;lt;code&amp;gt;Test Users&amp;lt;/code&amp;gt; section.&lt;br /&gt;
* Click &amp;lt;code&amp;gt;Add users&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Enter your NCSU email, press enter, and save.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== 5: Create OAuth client credentials ==&lt;br /&gt;
&lt;br /&gt;
# Select &amp;lt;code&amp;gt;Clients&amp;lt;/code&amp;gt; from the side navigation.&lt;br /&gt;
# Click &amp;lt;code&amp;gt;Create Client&amp;lt;/code&amp;gt; at the top of the screen.&lt;br /&gt;
# Application type: '''Web application'''.&lt;br /&gt;
# Name it &amp;quot;Expertiza SSO&amp;quot;.&lt;br /&gt;
#* Append Dev or Test to the name if necessary.&lt;br /&gt;
# Add '''Authorized redirect URI''':&lt;br /&gt;
#* Enter the frontend auth callback URL.&lt;br /&gt;
#** Local Dev should have the redirect URI from Step 1.&lt;br /&gt;
# Click Save.&lt;br /&gt;
&lt;br /&gt;
'''Important:''' This is the only time you will see the secret.&lt;br /&gt;
&lt;br /&gt;
The credential window gives the option to download the ID and secret as JSON.&lt;br /&gt;
&lt;br /&gt;
If this is for production, download the JSON and save it to a secure key vault for future use/reference.&lt;br /&gt;
&lt;br /&gt;
# Copy:&lt;br /&gt;
#* '''Client ID'''&lt;br /&gt;
#* '''Client Secret'''&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== 6: Configure app environment variables ==&lt;br /&gt;
&lt;br /&gt;
Set these environment variables for the Rails application:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;GOOG_CLIENT_ID&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;GOOG_CLIENT_SECRET&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;GOOG_REDIRECT_URI&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Examples:&lt;br /&gt;
&lt;br /&gt;
* PowerShell&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;powershell&amp;quot;&amp;gt;&lt;br /&gt;
$env:GOOG_CLIENT_ID=&amp;quot;&amp;lt;app_client_id&amp;gt;&amp;quot;&lt;br /&gt;
$env:GOOG_CLIENT_SECRET=&amp;quot;&amp;lt;app_client_secret&amp;gt;&amp;quot;&lt;br /&gt;
$env:GOOG_REDIRECT_URI=&amp;quot;&amp;lt;http_frontend_baseurl&amp;gt;/auth/callback&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* bash / WSL&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
export GOOG_CLIENT_ID=&amp;quot;&amp;lt;app_client_id&amp;gt;&amp;quot;&lt;br /&gt;
export GOOG_CLIENT_SECRET=&amp;quot;&amp;lt;app_client_secret&amp;gt;&amp;quot;&lt;br /&gt;
export GOOG_REDIRECT_URI=&amp;quot;&amp;lt;http_frontend_baseurl&amp;gt;/auth/callback&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If you use Docker Compose:&lt;br /&gt;
&lt;br /&gt;
* You can either add the env settings directly in Compose environment settings.&lt;br /&gt;
** If you use this option, do '''not''' commit the Compose file with secrets.&lt;br /&gt;
* You can create a &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; file and have Docker Compose reference it. Then put the OIDC info in your env file and redeploy with Docker Compose.&lt;br /&gt;
&lt;br /&gt;
Docker &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; example:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;ini&amp;quot;&amp;gt;&lt;br /&gt;
GOOG_CLIENT_ID='xxxxxxx.apps.googleusercontent.com'&lt;br /&gt;
GOOG_CLIENT_SECRET='XXXXXX-xxxxxxxxxxxxxxxxxxxxxxxxx'&lt;br /&gt;
GOOG_REDIRECT_URI='http://localhost:3000/auth/callback'&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== 7: Verify provider config ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt; should match:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;issuer: https://accounts.google.com&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;client_id: &amp;lt;%= ENV['GOOG_CLIENT_ID'] %&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;client_secret: &amp;lt;%= ENV['GOOG_CLIENT_SECRET'] %&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;redirect_uri: &amp;lt;%= ENV['GOOG_REDIRECT_URI'] %&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; is currently optional in this repo.&lt;br /&gt;
&lt;br /&gt;
If omitted, &amp;lt;code&amp;gt;OidcConfig.scopes_for&amp;lt;/code&amp;gt; defaults to:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;openid email profile&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If you want to restrict to email-focused login:&lt;br /&gt;
&lt;br /&gt;
* Keep &amp;lt;code&amp;gt;openid email&amp;lt;/code&amp;gt;&lt;br /&gt;
* Remove &amp;lt;code&amp;gt;profile&amp;lt;/code&amp;gt; from the actual scope source:&lt;br /&gt;
** If the provider defines &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;config/oidc_providers.yml&amp;lt;/code&amp;gt;, set it to &amp;lt;code&amp;gt;openid email&amp;lt;/code&amp;gt;&lt;br /&gt;
** If &amp;lt;code&amp;gt;scopes&amp;lt;/code&amp;gt; is omitted, the fallback comes from &amp;lt;code&amp;gt;OidcConfig.scopes_for&amp;lt;/code&amp;gt;, so update that default if you want repo-wide behavior without &amp;lt;code&amp;gt;profile&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== 8: Quick runtime validation ==&lt;br /&gt;
&lt;br /&gt;
# Start the backend.&lt;br /&gt;
# Call provider list endpoint:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
GET /auth/providers&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Expected result: includes &amp;lt;code&amp;gt;google-ncsu&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
If you get an empty array, confirm env vars are present in the running process.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== 9: Common issues ==&lt;br /&gt;
&lt;br /&gt;
=== Empty &amp;lt;code&amp;gt;/auth/providers&amp;lt;/code&amp;gt; ===&lt;br /&gt;
&lt;br /&gt;
* Usually means one or more required provider fields are blank after ERB eval.&lt;br /&gt;
* Most often &amp;lt;code&amp;gt;GOOG_CLIENT_ID&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;GOOG_CLIENT_SECRET&amp;lt;/code&amp;gt;, or &amp;lt;code&amp;gt;GOOG_REDIRECT_URI&amp;lt;/code&amp;gt; is missing.&lt;br /&gt;
* Error reporting can be found in the Rails logs.&lt;br /&gt;
&lt;br /&gt;
=== Redirect URI mismatch ===&lt;br /&gt;
&lt;br /&gt;
* Google and app must match '''exactly''' (scheme, host, port, path).&lt;br /&gt;
&lt;br /&gt;
=== &amp;lt;code&amp;gt;invalid_client&amp;lt;/code&amp;gt; ===&lt;br /&gt;
&lt;br /&gt;
* Wrong client ID/secret pair, or credentials from a different project.&lt;br /&gt;
&lt;br /&gt;
=== Consent screen / test user errors ===&lt;br /&gt;
&lt;br /&gt;
* Add your account as test user if app is in Testing mode.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== 10: Production notes ==&lt;br /&gt;
&lt;br /&gt;
* Use production redirect URI (HTTPS).&lt;br /&gt;
* Store secrets in a secure secret manager or deployment environment.&lt;br /&gt;
* '''Do not commit credentials to git.'''&lt;/div&gt;</summary>
		<author><name>Jvargas6</name></author>
	</entry>
	<entry>
		<id>https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2604._Finish_Password_Resets&amp;diff=167566</id>
		<title>CSC/ECE 517 Spring 2026 - E2604. Finish Password Resets</title>
		<link rel="alternate" type="text/html" href="https://wiki.expertiza.ncsu.edu/index.php?title=CSC/ECE_517_Spring_2026_-_E2604._Finish_Password_Resets&amp;diff=167566"/>
		<updated>2026-03-28T01:10:03Z</updated>

		<summary type="html">&lt;p&gt;Jvargas6: /* Demo Video */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Demo Video ==&lt;br /&gt;
[https://drive.google.com/file/d/1Piw2dpUQ5ldI9X1mdROg4crYI9QWxuiP/view?usp=drive_link Click to watch the demo video]&lt;br /&gt;
&lt;br /&gt;
You can also [https://www.youtube.com/watch?v=gWbL0l0GJ3c Watch it on YouTube]&lt;br /&gt;
&lt;br /&gt;
== Expertiza Background ==&lt;br /&gt;
&lt;br /&gt;
=== What is Expertiza? ===&lt;br /&gt;
'''Expertiza''' is an open-source educational web application built with Ruby on Rails and jointly maintained by students and faculty at North Carolina State University (NCSU). It is available publicly on GitHub.&lt;br /&gt;
&lt;br /&gt;
It supports:&lt;br /&gt;
&lt;br /&gt;
* Creating and configuring assignments (individual or team-based).&lt;br /&gt;
* Allowing students to submit work (files, links, etc.).&lt;br /&gt;
* Enabling peer review and teammate evaluation.&lt;br /&gt;
* Letting instructors define rubrics and grading criteria.&lt;br /&gt;
&lt;br /&gt;
Expertiza is designed to promote learning through iterative feedback. Students review each other’s work, reflect, improve, and resubmit. Instructors can monitor progress, manage deadlines, and assess performance.&lt;br /&gt;
&lt;br /&gt;
== Reimplementation Repository ==&lt;br /&gt;
=== Front End Overview ===&lt;br /&gt;
The '''reimplementation-front-end''' project replaces Expertiza's aging Rails monolith (server-rendered ERB templates, jQuery, session cookies, no client-side state) with a decoupled React + TypeScript SPA using JWT auth, Redux, Formik/Yup forms, React Bootstrap, and Vite — separating the front end entirely from the Rails API back end.&lt;br /&gt;
=== Back End Overview ===&lt;br /&gt;
The '''reimplementation-back-end''' project transforms Expertiza from a server-rendered Rails monolith into a pure JSON REST API using stateless JWT/RSA-256 authentication, a centralized role-based authorization concern, and adds several new features including a Duties system for team role assignment, a MentoredTeam type, a TeamsParticipant join model replacing TeamsUser, an Item/Strategy pattern replacing the Question STI hierarchy, a StudentTask service object, multi-view grade reporting endpoints, a self-registration AccountRequest workflow, full invitation lifecycle management, and auto-generated Swagger API documentation.&lt;br /&gt;
&lt;br /&gt;
== Project Background ==&lt;br /&gt;
&lt;br /&gt;
=== Motivation ===&lt;br /&gt;
Account recovery and password reset functionality is a critical part of any authentication system. The current site lacks a functional password reset or &amp;quot;forgot password&amp;quot; feature, representing a significant gap in both usability and security.&lt;br /&gt;
&lt;br /&gt;
This created several issues:&lt;br /&gt;
&lt;br /&gt;
* Without a working reset mechanism, there is no sanctioned path for users to change this default credential after account creation. This constitutes a vulnerability that persists indefinitely if left unaddressed.&lt;br /&gt;
* A user who forgets their credentials has no means of recovering access independently.&lt;br /&gt;
* A standards-compliant password reset mechanism relies on time-limited, single-use tokens delivered to a verified email address. This ensures that only the legitimate account owner can initiate a reset. Without such a mechanism, any ad-hoc reset approach would be either insecure or unverifiable (e.g., allowing resets without proving email ownership).&lt;br /&gt;
&lt;br /&gt;
This project introduces a more structured workflow for handling password reset and account recovery operations.&lt;br /&gt;
&lt;br /&gt;
The goal is to provide a consistent and secure process for users who need to reset or recover their credentials while maintaining proper validation and access control.&lt;br /&gt;
&lt;br /&gt;
Typical operations in this workflow include:&lt;br /&gt;
&lt;br /&gt;
* requesting a password reset&lt;br /&gt;
* generating a secure reset token&lt;br /&gt;
* validating reset requests&lt;br /&gt;
* updating the user's password&lt;br /&gt;
* preventing unauthorized reset attempts&lt;br /&gt;
&lt;br /&gt;
The system should ensure that these steps are handled in a secure, predictable, and maintainable way.&lt;br /&gt;
&lt;br /&gt;
== Objectives ==&lt;br /&gt;
&lt;br /&gt;
=== 1. Implement a unified password reset workflow ===&lt;br /&gt;
&lt;br /&gt;
The system should support a consistent flow for recovering user accounts.&lt;br /&gt;
&lt;br /&gt;
Typical steps include:&lt;br /&gt;
&lt;br /&gt;
* User requests password reset&lt;br /&gt;
* System generates a reset token&lt;br /&gt;
* User receives reset instructions via email&lt;br /&gt;
* User accesses password reset page via email link&lt;br /&gt;
* User submits new password&lt;br /&gt;
* System validates and updates credentials&lt;br /&gt;
&lt;br /&gt;
This workflow should be easy to maintain and extend.&lt;br /&gt;
&lt;br /&gt;
=== 2. Improve security and validation ===&lt;br /&gt;
&lt;br /&gt;
Password reset functionality must enforce security best practices.&lt;br /&gt;
&lt;br /&gt;
This may include:&lt;br /&gt;
&lt;br /&gt;
* Ensure forgot password mechanism adheres to [https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html#url-tokens industry standards].&lt;br /&gt;
* Ensure reset links are generated correctly across development, staging, and production environments.&lt;br /&gt;
* Validate forgot and reset password mechanism functions through unit, functional, performance, and integration testing.&lt;br /&gt;
&lt;br /&gt;
The system should ensure that only authorized password reset requests are processed.&lt;br /&gt;
&lt;br /&gt;
=== 3. Improve frontend robustness and validation ===&lt;br /&gt;
The password reset workflow involves several frontend steps, including requesting a reset link and submitting a new password. The frontend should handle various edge cases gracefully.&lt;br /&gt;
&lt;br /&gt;
This project includes a review and refinement of the frontend behavior to ensure consistent handling of errors and validation responses.&lt;br /&gt;
&lt;br /&gt;
Key areas of focus include:&lt;br /&gt;
&lt;br /&gt;
* Handling invalid or expired password reset tokens&lt;br /&gt;
* Properly displaying backend validation errors&lt;br /&gt;
* Ensuring clear success and failure messages for users&lt;br /&gt;
* Confirming that form validation aligns with backend rules&lt;br /&gt;
&lt;br /&gt;
The goal is to ensure that the password reset workflow remains reliable and user-friendly.&lt;br /&gt;
&lt;br /&gt;
== Core Changes ==&lt;br /&gt;
&lt;br /&gt;
In this project:&lt;br /&gt;
&lt;br /&gt;
* The '''User model''' or authentication model was enhanced.&lt;br /&gt;
* A new '''password reset workflow''' was introduced.&lt;br /&gt;
* Controller logic was updated to handle reset requests.&lt;br /&gt;
* Routes were added to support password reset endpoints.&lt;br /&gt;
* Tests were added to verify authentication and recovery behavior.&lt;br /&gt;
&lt;br /&gt;
== Password Reset Workflow ==&lt;br /&gt;
&lt;br /&gt;
=== Purpose ===&lt;br /&gt;
The password reset workflow allows users who have forgotten their password to regain access to their account securely.&lt;br /&gt;
&lt;br /&gt;
A typical password reset flow may look like:&lt;br /&gt;
&lt;br /&gt;
* User selects &amp;quot;Forgot Password&amp;quot; in the login page.&lt;br /&gt;
* User provides email address associated with account.&lt;br /&gt;
* System generates a reset token.&lt;br /&gt;
* System sends reset email containing link with password token in it.&lt;br /&gt;
* User navigates to password reset portal and creates a new password.&lt;br /&gt;
* System validates the request and updates credentials&lt;br /&gt;
&lt;br /&gt;
This workflow must ensure both usability and security.&lt;br /&gt;
&lt;br /&gt;
=== Implementation ===&lt;br /&gt;
&lt;br /&gt;
[[File:Forgotyourpassword.png]]&lt;br /&gt;
&lt;br /&gt;
The PasswordsController handles two operations in the password reset flow: initiating a reset request and completing it with a new password. Both actions bypass the standard JWT authentication (skip_before_action :authenticate_request!), as they are designed for unauthenticated users.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;br&amp;gt;''When a user submits their email to request a password reset.''&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;rb&amp;quot;&amp;gt;&lt;br /&gt;
# POST /password_resets&lt;br /&gt;
def create&lt;br /&gt;
  if @user&lt;br /&gt;
    token = @user.generate_token_for(:password_reset)&lt;br /&gt;
    UserMailer.send_password_reset_email(@user, token).deliver_later&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  # Always return a 200 OK to prevent email enumeration attacks&lt;br /&gt;
  render json: { message: I18n.t('password_reset.email_sent') }, status: :ok&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;br&amp;gt;''A before_action for create. It looks up the user-submitted email address and does nothing if no user is found.''&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;rb&amp;quot;&amp;gt;&lt;br /&gt;
def find_user_by_email&lt;br /&gt;
  @user = User.find_by(email: params[:email])&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;br&amp;gt;''Assuming the email is valid, a password token is dispatched along with an HTML format email.''&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;rb&amp;quot;&amp;gt;&lt;br /&gt;
generates_token_for :password_reset, expires_in: 15.minutes do&lt;br /&gt;
  password_salt&amp;amp;.last(10) || updated_at.to_s&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;br&amp;gt;''Navigating to the URL presents the user with the following password reset page.''&amp;lt;br&amp;gt;&lt;br /&gt;
[[File:ExpertizaPasswordReset.png]]&lt;br /&gt;
&lt;br /&gt;
&amp;lt;br&amp;gt;''When a user submits a new password using the token from the email link.''&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;rb&amp;quot;&amp;gt;&lt;br /&gt;
# PATCH/PUT /password_resets/:token&lt;br /&gt;
def update&lt;br /&gt;
  if @user.update(password_params)&lt;br /&gt;
    render json: { message: I18n.t('password_reset.updated') }, status: :ok&lt;br /&gt;
  else&lt;br /&gt;
    render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;br&amp;gt;''A before_action for update. It validates and decodes the reset token from the URL.''&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;rb&amp;quot;&amp;gt;&lt;br /&gt;
def find_user_by_token&lt;br /&gt;
  @user = User.find_by_token_for(:password_reset, params[:token])&lt;br /&gt;
&lt;br /&gt;
  unless @user&lt;br /&gt;
    render json: { error: I18n.t('password_reset.errors.token_expired') }, status: :unprocessable_entity&lt;br /&gt;
  end&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;br&amp;gt;''If the reset token is invalid or expired(find_by_token_for returns nil), the method renders a 422 Unprocessable Entity error''&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;rb&amp;quot;&amp;gt;&lt;br /&gt;
def password_params&lt;br /&gt;
  params.require(:user).permit(:password, :password_confirmation)&lt;br /&gt;
end&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== How it works - Token Generation ===&lt;br /&gt;
* Tokens are generated using Rails 7.1's built-in generates_token_for API.&lt;br /&gt;
** When a reset is requested, PasswordsController#create calls @user.generate_token_for(:password_reset), which produces a cryptographically signed token containing:&lt;br /&gt;
*** A purpose tag (password_reset) so the token cannot be reused for other token-based operations.&lt;br /&gt;
*** An expiry timestamp embedded in the token (15-minute window).&lt;br /&gt;
*** A fingerprint derived from the last 10 characters of the user's password_salt, falling back to updated_at&lt;br /&gt;
&lt;br /&gt;
=== How it works - Token Validation ===&lt;br /&gt;
* Validation occurs in PasswordsController#find_user_by_token, run as a before_action on the update endpoint:&lt;br /&gt;
** Rails' find_by_token_for performs several checks atomically:&lt;br /&gt;
*** Signature verification — the token's HMAC signature must be valid (tampered tokens fail immediately).&lt;br /&gt;
*** Purpose check — the token must have been generated for :password_reset specifically.&lt;br /&gt;
*** Expiry check — the embedded timestamp must be within the 15-minute window.&lt;br /&gt;
*** Fingerprint check — the password_salt fragment in the token must match the user's current state.&lt;br /&gt;
** If any check fails, nil is returned and the controller renders a 422 Unprocessable Entity with a generic error message (&amp;quot;The token has expired or is invalid.&amp;quot;).&lt;br /&gt;
*** This single error message intentionally does not distinguish between expired vs. tampered tokens, avoiding information leakage.&lt;br /&gt;
&lt;br /&gt;
=== How it works - Secure Password Update ===&lt;br /&gt;
* Once the token is validated, the update action applies the new password.&lt;br /&gt;
** Strong parameters filter the request, permitting only :password and :password_confirmation&lt;br /&gt;
*** The user has to be able to confirm the password by writing it again.&lt;br /&gt;
** At the storage layer:&lt;br /&gt;
*** has_secure_password delegates hashing to BCrypt via Rails' ActiveModel::SecurePassword. The raw password is never stored.&lt;br /&gt;
*** password_confirmation matching is enforced by has_secure_password — Rails raises a validation error if the two fields don't match.&lt;br /&gt;
*** Minimum length validation (length: { minimum: 6 }) ensures weak passwords are rejected before reaching the database.&lt;br /&gt;
*** Upon a successful save, password_salt changes, which immediately invalidates the reset token used.&lt;br /&gt;
&lt;br /&gt;
== Security Considerations ==&lt;br /&gt;
&lt;br /&gt;
Password reset systems must be designed carefully to prevent abuse.&lt;br /&gt;
&lt;br /&gt;
'''Potential risks include:'''&lt;br /&gt;
* Unauthorized password changes&lt;br /&gt;
* Brute-force token guessing&lt;br /&gt;
* Replay attacks using old reset links&lt;br /&gt;
* Account enumeration&lt;br /&gt;
* Token leakage via URL&lt;br /&gt;
&lt;br /&gt;
'''The system addresses these risks:'''&lt;br /&gt;
* A request cannot reach the password update logic without first passing token validation. The update action is gated by find_user_by_token as a before_action.&lt;br /&gt;
* Tokens produced by Rails' generates_token_for are HMAC-signed using the application's secret_key_base. They are not short numeric codes or predictable sequences — they are cryptographically random and verifiable only by the server.&lt;br /&gt;
* When a password is successfully updated via has_secure_password, the password_salt changes. This causes the embedded fingerprint to no longer match, immediately invalidating the used token. Old links cannot be reused even within the 15-minute window.&lt;br /&gt;
* The create action always returns 200 OK with the same message (&amp;quot;If the email exists, a reset link has been sent.&amp;quot;) regardless of whether the submitted email matches a real account.&lt;br /&gt;
* The 15-minute expiry limits the window of exposure, and the token self-invalidates after use, so a leaked-but-used token is harmless. A leaked-but-unused token within 15 minutes remains a risk, but this is a standard trade-off for URL-based reset flows.&lt;br /&gt;
&lt;br /&gt;
== Testing and Verification ==&lt;br /&gt;
&lt;br /&gt;
[https://drive.google.com/ Test Verification Video] -&amp;gt; TODO&lt;br /&gt;
&lt;br /&gt;
=== Goals of Testing ===&lt;br /&gt;
&lt;br /&gt;
The testing strategy verifies that password reset functionality works correctly and securely.&lt;br /&gt;
&lt;br /&gt;
Key goals include:&lt;br /&gt;
&lt;br /&gt;
* Verifying reset request behavior&lt;br /&gt;
* Verifying token validation&lt;br /&gt;
* Verifying password updates&lt;br /&gt;
* Verifying proper error handling&lt;br /&gt;
&lt;br /&gt;
=== Frontend Tests ===&lt;br /&gt;
&lt;br /&gt;
The front end implementation achieves these goals by verifying that:&lt;br /&gt;
* Pages render the correct UI elements (headings, inputs, and submit buttons)&lt;br /&gt;
* Form validation prevents submission with invalid or mismatched input and displays appropriate error messages&lt;br /&gt;
* Successful API calls dispatch the correct success alerts to the user&lt;br /&gt;
* Failed API calls are handled gracefully, surfacing either a generic fallback or a server-provided error message&lt;br /&gt;
* Token validation on the reset page redirects unauthenticated users back to login&lt;br /&gt;
&lt;br /&gt;
=== Backend Tests ===&lt;br /&gt;
&lt;br /&gt;
The back end implementation achieves these goals by verifying that:&lt;br /&gt;
* Submitting a forgot-password request always returns HTTP 200 regardless of whether the email exists, preventing user enumeration&lt;br /&gt;
* A valid reset token triggers a password reset email sent to the correct user, containing the correct subject and a tokenized reset URL&lt;br /&gt;
* Successfully submitting a valid token with a new password updates the user's stored password_digest and returns HTTP 200&lt;br /&gt;
* Invalid, malformed, or expired tokens (&amp;gt;15 minutes) are rejected with HTTP 422 and an appropriate error message&lt;br /&gt;
* Password validation rules (minimum 6 characters) are enforced during the reset flow&lt;br /&gt;
&lt;br /&gt;
== Collaborators ==&lt;br /&gt;
* Mentor: Prerak Manish Bhandari&lt;br /&gt;
* Jose Vargas&lt;br /&gt;
* John Weisz&lt;br /&gt;
* Jared Monseur&lt;/div&gt;</summary>
		<author><name>Jvargas6</name></author>
	</entry>
</feed>