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


