CSC/ECE 517 Spring 2026 - E2618. Support OIDC Logins

From Expertiza_Wiki
Jump to navigation Jump to search

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-configuration document.
  • 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.yml with secrets injected from environment variables via ERB. Each provider entry defines a display name, scopes, issuer, client credentials, and redirect URI. The OidcConfig class validates that all required keys are present when accessed. In production, invalid configuration raises OidcConfig::InvalidConfiguration to prevent startup with a misconfigured provider. In other environments, invalid providers are skipped with a warning to avoid blocking local development and CI where OIDC may not be fully configured. Validation runs at boot via config/initializers/oidc.rb so issues surface immediately in production.
  • Provider List (Step 1): Expose a GET /auth/providers endpoint that returns a JSON array of { id, name } from OidcConfig.public_list. No secrets or endpoint details are included in this response.
  • Client Select (Step 2): Expose a POST /auth/client-select endpoint that accepts a provider id and username. Both parameters are required; missing parameters return a 400. Fetch the provider's .well-known/openid-configuration document using the openid_connect gem to resolve endpoints and JWKS keys. Generate a cryptographically random state and nonce via SecureRandom.hex(32), and a PKCE code verifier via SecureRandom.urlsafe_base64(64) with a SHA256 code challenge. Insert a row into the oidc_requests table containing the state, nonce, code verifier, provider id, username, and creation timestamp. With a 10% probability per request, enqueue a CleanupStaleOidcRequestsJob to amortize the cost of deleting stale rows without requiring a dedicated scheduler. Construct the authorization URL using the openid_connect gem's authorization_uri method and return it to the frontend.
  • Callback (Step 4): Expose a POST /auth/callback endpoint that accepts the authorization code and state. Both parameters are required. Atomically look up and destroy the matching oidc_requests row by state within a database transaction with row-level locking, rejecting the request if no row is found or if the row is older than 5 minutes. The atomic consume prevents replay. Using the openid_connect gem, exchange the authorization code for tokens via access_token! with the stored code verifier. Decode the ID token using OpenIDConnect::ResponseObject::IdToken.decode against the provider's JWKS keys, and verify the issuer, client_id, and nonce via id_token.verify!. The email_verified claim must be explicitly true; tokens without it or with a false value are rejected. Match the user by both the stored username and the verified email claim from the ID token using case-insensitive, whitespace-trimmed comparison (emails are not unique in Expertiza, so username disambiguates). If a match is found, issue a session JWT via user.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 OidcModal component calls GET /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.

Login Page with SSO Button SSO Login Modal

  • Initiate Login (Step 2): Once the user enters their username, selects a provider, and clicks "Continue with SSO", the form posts to /auth/client-select with both the provider id and username. On success, the browser is redirected to the returned authorization URL via window.location.href. The user then authenticates with the identity provider and is redirected back to the frontend callback route.
  • Callback (Step 4): The OidcCallback page component handles the redirect back from the identity provider at /auth/callback. It extracts the authorization code and state from the query parameters and POSTs them to /auth/callback. If the query parameters contain an error 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.
  • Login Complete (Step 5): On a successful callback response, we store the session JWT via setAuthToken, update the Redux auth state via authenticationActions.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! and OidcLoginController#callback operate on whatever provider key is passed in, with no if provider == "google" branching. Adding a new institution's SSO requires only a new YAML entry and environment variables, no code changes anywhere.
  • SingletonOidcConfig is effectively a singleton: provider configuration is memoized as class-level state and cleared via reload!, ensuring YAML is parsed once per process. Ruby's Singleton module was not used because it would require changing every call site from OidcConfig.find(...) to OidcConfig.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_requests rows are cleaned up on a small percentage (10%) of new request creations rather than scheduled. This amortizes cleanup cost across normal usage and avoids adding a scheduler dependency to the application.

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_connect 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 openid_connect directly 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-select is rate-limited to prevent table-fill attacks and discovery spam (each request creates a row and triggers an IdP discovery call). POST /auth/callback is rate-limited to slow brute-force state guessing — even though the state values have 256 bits of entropy and won't realistically be guessed, throttling prevents wasted work and log noise.
  • 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.rb and 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

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                               — Added generate_jwt method shared with password login
app/jobs/cleanup_stale_oidc_requests_job.rb      — ActiveJob that calls OidcRequest.delete_stale
config/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 the OidcModal component added below the password form
src/App.tsx                                 — Added /auth/callback route

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 CleanupStaleOidcRequestsJob is enqueued only when rand falls under the configured threshold.
  • .authorization_uri_for! — Creates an oidc_requests row with the username, returns a properly-formed authorization URI, falls back to default scopes when none are configured, and rejects duplicate state values via the unique index.
  • #verified_email_from_code! — Returns the email when email_verified is true, raises AuthenticationError when the claim is absent or false, and raises InvalidToken when the nonce doesn't match.
  • #authenticate_user! — Matches users by username and email with case-insensitive, whitespace-tolerant comparison; raises AuthenticationError when either field doesn't match or the email is blank.
  • .new_client — Builds an OpenIDConnect::Client with 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 InvalidConfiguration in 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 ProviderNotFound for 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-After header 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-select and 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/callback on mount, stores the JWT and dispatches auth state on success, redirects to the dashboard, displays an error alert and redirects to login on failure, handles the IdP error query 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.yml with ERB support for injecting secrets from environment variables.
  • Create an OidcConfig class 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::InvalidConfiguration to 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.rb so issues surface immediately on deploy.
  • Add unit tests for config loading, validation, missing key detection, scope normalization, and public_list secrets 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_requests with columns: state (string, not null, unique, indexed), nonce (not null), code_verifier (not null), provider (not null), username (not null), and created_at.
  • Create the OidcRequest model with a consume_recent_by_state! method that atomically finds, locks, and destroys the row in a transaction to prevent replay.
  • Expose a delete_stale class 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 } from OidcConfig.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 provider and username params (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_requests with 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 code and state params (return 400 on missing params).
  • Atomically consume the matching oidc_requests row by state, rejecting if not found, expired, or already consumed.
  • Exchange the code for tokens using the openid_connect gem with the stored code_verifier.
  • Verify the ID token signature (JWKS), issuer, audience (client_id), and nonce.
  • Reject the login if the email_verified claim is not explicitly true.
  • 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_jwt and 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 OidcModal component that calls GET /auth/providers on mount.
  • Display an SSO button when providers are returned.
  • On button press, open a modal with a username text input and a Form.Select dropdown 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, POST to /auth/client-select with 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/callback route in the React router pointing to the OidcCallback component.
  • Extract code and state from query parameters and POST them to /auth/callback.
  • If the query parameters contain an error param (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, dispatch authenticationActions.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 User model (user.generate_jwt).
  • Update AuthenticationController#login to use the shared method without changing its external response shape.
  • Use the shared method in OidcLoginController#callback.
  • Add model specs for User#generate_jwt covering 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, and Login components.
  • 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, and POST /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 CleanupStaleOidcRequestsJob ActiveJob that calls OidcRequest.delete_stale.
  • On after_create of OidcRequest, enqueue the job with a 10% probability (CLEANUP_PROBABILITY).
  • Use a VALIDITY_WINDOW constant 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 OidcRequest covering atomic state consumption, replay prevention, expiry, probabilistic cleanup enqueuing, case-insensitive user matching with whitespace normalization, strict email_verified handling, and PKCE code verifier flow.
  • Model specs for OidcConfig covering YAML loading, ERB interpolation, memoization and reload, missing key detection (warn in dev, raise in production), scope normalization, and public_list secrets exclusion.
  • Model specs for User#generate_jwt covering payload structure, default and custom expiry, and rejection of tampered tokens.
  • Verify the existing AuthenticationController#login specs 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-select payload.
    • 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/callback on 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 error query 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.
  • Verify the existing login page renders and functions correctly with and without the OidcModal component.

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-attack gem 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