Compare commits
13 Commits
6049429a41
...
feature/en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e882a4d6d1 | ||
|
|
ab0085e9c9 | ||
|
|
1ee3302319 | ||
|
|
67f28faaca | ||
|
|
33ad956508 | ||
|
|
11ec753c68 | ||
|
|
4df2eee4d9 | ||
|
|
d9f11abbbf | ||
|
|
c92e69fa4a | ||
|
|
038801f34b | ||
|
|
8e0b2c28eb | ||
|
|
f02665f690 | ||
|
|
631b2b53bb |
33
README.md
33
README.md
@@ -13,7 +13,8 @@ I've completed all planned features:
|
||||
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
|
||||
* Passkey generation and login, with detection of Passkey during login
|
||||
* Forward Auth configured and working
|
||||
* OIDC provider with auto discovery working
|
||||
* OIDC provider with auto discovery, refresh tokens, and token revocation
|
||||
* Configurable token expiry per application (access, refresh, ID tokens)
|
||||
* Invite users by email, assign to groups
|
||||
* Self managed password reset by email
|
||||
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
|
||||
@@ -86,11 +87,17 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
||||
#### OpenID Connect (OIDC)
|
||||
Standard OAuth2/OIDC provider with endpoints:
|
||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||
- `/authorize` - Authorization endpoint
|
||||
- `/token` - Token endpoint
|
||||
- `/authorize` - Authorization endpoint with PKCE support
|
||||
- `/token` - Token endpoint (authorization_code and refresh_token grants)
|
||||
- `/userinfo` - User info endpoint
|
||||
- `/revoke` - Token revocation endpoint (RFC 7009)
|
||||
|
||||
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens.
|
||||
Features:
|
||||
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
|
||||
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
|
||||
- **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens
|
||||
|
||||
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
|
||||
|
||||
#### Trusted-Header SSO (ForwardAuth)
|
||||
Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||
@@ -156,25 +163,29 @@ Send emails for:
|
||||
- Redirect URIs (for OIDC apps)
|
||||
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
|
||||
- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
|
||||
- Token TTL configuration (access_token_ttl, refresh_token_ttl, id_token_ttl)
|
||||
- Metadata (flexible JSON storage)
|
||||
- Active flag
|
||||
- Many-to-many with Groups (allowlist)
|
||||
|
||||
**OIDC Tokens**
|
||||
- Authorization codes (10-minute expiry, one-time use)
|
||||
- Access tokens (1-hour expiry, revocable)
|
||||
- Authorization codes (10-minute expiry, one-time use, PKCE support)
|
||||
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable)
|
||||
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation)
|
||||
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
|
||||
|
||||
---
|
||||
|
||||
## Authentication Flows
|
||||
|
||||
### OIDC Authorization Flow
|
||||
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope
|
||||
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope (optional PKCE)
|
||||
2. User authenticates with Clinch (username/password + optional TOTP)
|
||||
3. Access control check: Is user in an allowed group for this app?
|
||||
4. If allowed, generate authorization code and redirect to client
|
||||
5. Client exchanges code for access token at `/token`
|
||||
6. Client uses access token to fetch user info from `/userinfo`
|
||||
5. Client exchanges code at `/token` for ID token, access token, and refresh token
|
||||
6. Client uses access token to fetch fresh user info from `/userinfo`
|
||||
7. When access token expires, client uses refresh token to get new tokens (no re-authentication)
|
||||
|
||||
### ForwardAuth Flow
|
||||
1. User requests protected resource at `https://app.example.com/dashboard`
|
||||
@@ -258,6 +269,10 @@ SMTP_ENABLE_STARTTLS=true
|
||||
# Application
|
||||
CLINCH_HOST=https://auth.example.com
|
||||
CLINCH_FROM_EMAIL=noreply@example.com
|
||||
|
||||
# OIDC (optional - generates temporary key in development)
|
||||
# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||
OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
|
||||
```
|
||||
|
||||
### First Run
|
||||
|
||||
@@ -99,7 +99,8 @@ module Admin
|
||||
def application_params
|
||||
params.require(:application).permit(
|
||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||
:domain_pattern, :landing_url, headers_config: {}
|
||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||
headers_config: {}
|
||||
).tap do |whitelisted|
|
||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||
whitelisted.delete(:client_secret)
|
||||
|
||||
@@ -10,6 +10,13 @@ module Api
|
||||
report_data = JSON.parse(request.body.read)
|
||||
csp_report = report_data['csp-report']
|
||||
|
||||
# Validate that we have a proper CSP report
|
||||
unless csp_report.is_a?(Hash) && csp_report.present?
|
||||
Rails.logger.warn "Received empty or invalid CSP violation report"
|
||||
head :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Log the violation for security monitoring
|
||||
Rails.logger.warn "CSP Violation Report:"
|
||||
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
||||
|
||||
@@ -221,7 +221,9 @@ module Api
|
||||
|
||||
# Try CLINCH_HOST environment variable first
|
||||
if ENV['CLINCH_HOST'].present?
|
||||
"https://#{ENV['CLINCH_HOST']}"
|
||||
host = ENV['CLINCH_HOST']
|
||||
# Ensure URL has https:// protocol
|
||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||
else
|
||||
# Fallback to the request host
|
||||
request_host = request.host || request.headers['X-Forwarded-Host']
|
||||
|
||||
@@ -5,4 +5,7 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
# Changes to the importmap will invalidate the etag for HTML responses
|
||||
stale_when_importmap_changes
|
||||
|
||||
# CSRF protection
|
||||
protect_from_forgery with: :exception
|
||||
end
|
||||
|
||||
@@ -120,11 +120,11 @@ module Authentication
|
||||
# Generate a secure random token
|
||||
token = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
# Store it with an expiry of 30 seconds
|
||||
# Store it with an expiry of 60 seconds
|
||||
Rails.cache.write(
|
||||
"forward_auth_token:#{token}",
|
||||
session_obj.id,
|
||||
expires_in: 30.seconds
|
||||
expires_in: 60.seconds
|
||||
)
|
||||
|
||||
# Set the token as a query parameter on the redirect URL
|
||||
@@ -134,13 +134,16 @@ module Authentication
|
||||
original_url = controller_session[:return_to_after_authenticating]
|
||||
uri = URI.parse(original_url)
|
||||
|
||||
# Add token as query parameter
|
||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||
query_params['fa_token'] = token
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||
unless uri.path&.start_with?("/oauth/")
|
||||
# Add token as query parameter
|
||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||
query_params['fa_token'] = token
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
|
||||
# Update the session with the tokenized URL
|
||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||
# Update the session with the tokenized URL
|
||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class OidcController < ApplicationController
|
||||
# Discovery and JWKS endpoints are public
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout]
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :logout]
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
|
||||
|
||||
# GET /.well-known/openid-configuration
|
||||
def discovery
|
||||
@@ -11,15 +11,19 @@ class OidcController < ApplicationController
|
||||
issuer: base_url,
|
||||
authorization_endpoint: "#{base_url}/oauth/authorize",
|
||||
token_endpoint: "#{base_url}/oauth/token",
|
||||
revocation_endpoint: "#{base_url}/oauth/revoke",
|
||||
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
||||
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
||||
end_session_endpoint: "#{base_url}/logout",
|
||||
response_types_supported: ["code"],
|
||||
response_modes_supported: ["query"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
scopes_supported: ["openid", "profile", "email", "groups"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"]
|
||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
|
||||
code_challenge_methods_supported: ["plain", "S256"]
|
||||
}
|
||||
|
||||
render json: config
|
||||
@@ -32,30 +36,71 @@ class OidcController < ApplicationController
|
||||
|
||||
# GET /oauth/authorize
|
||||
def authorize
|
||||
# Get parameters
|
||||
# Get parameters (ignore forward auth tokens and other unknown params)
|
||||
client_id = params[:client_id]
|
||||
redirect_uri = params[:redirect_uri]
|
||||
state = params[:state]
|
||||
nonce = params[:nonce]
|
||||
scope = params[:scope] || "openid"
|
||||
response_type = params[:response_type]
|
||||
code_challenge = params[:code_challenge]
|
||||
code_challenge_method = params[:code_challenge_method] || "plain"
|
||||
|
||||
# Validate required parameters
|
||||
unless client_id.present? && redirect_uri.present? && response_type == "code"
|
||||
render plain: "Invalid request: missing required parameters", status: :bad_request
|
||||
error_details = []
|
||||
error_details << "client_id is required" unless client_id.present?
|
||||
error_details << "redirect_uri is required" unless redirect_uri.present?
|
||||
error_details << "response_type must be 'code'" unless response_type == "code"
|
||||
|
||||
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Validate PKCE parameters if present
|
||||
if code_challenge.present?
|
||||
unless %w[plain S256].include?(code_challenge_method)
|
||||
render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
||||
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||
render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Find the application
|
||||
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||
unless @application
|
||||
render plain: "Invalid client_id", status: :bad_request
|
||||
# Log all OIDC applications for debugging
|
||||
all_oidc_apps = Application.where(app_type: "oidc")
|
||||
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
|
||||
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||
|
||||
error_msg = if Rails.env.development?
|
||||
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
|
||||
else
|
||||
"Invalid request: Application not found"
|
||||
end
|
||||
|
||||
render plain: error_msg, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Validate redirect URI
|
||||
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
||||
render plain: "Invalid redirect_uri", status: :bad_request
|
||||
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
||||
|
||||
# For development, show detailed error
|
||||
error_msg = if Rails.env.development?
|
||||
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
|
||||
else
|
||||
"Invalid request: Redirect URI not registered for this application"
|
||||
end
|
||||
|
||||
render plain: error_msg, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -67,7 +112,9 @@ class OidcController < ApplicationController
|
||||
redirect_uri: redirect_uri,
|
||||
state: state,
|
||||
nonce: nonce,
|
||||
scope: scope
|
||||
scope: scope,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method
|
||||
}
|
||||
redirect_to signin_path, alert: "Please sign in to continue"
|
||||
return
|
||||
@@ -96,6 +143,8 @@ class OidcController < ApplicationController
|
||||
redirect_uri: redirect_uri,
|
||||
scope: scope,
|
||||
nonce: nonce,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
@@ -112,12 +161,34 @@ class OidcController < ApplicationController
|
||||
redirect_uri: redirect_uri,
|
||||
state: state,
|
||||
nonce: nonce,
|
||||
scope: scope
|
||||
scope: scope,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method
|
||||
}
|
||||
|
||||
# Render consent page
|
||||
# Render consent page with dynamic CSP for OAuth redirect
|
||||
@redirect_uri = redirect_uri
|
||||
@scopes = requested_scopes
|
||||
|
||||
# Add the redirect URI to CSP form-action for this specific request
|
||||
# This allows the OAuth redirect to work while maintaining security
|
||||
# CSP must allow the OAuth client's redirect_uri as a form submission target
|
||||
if redirect_uri.present?
|
||||
begin
|
||||
redirect_host = URI.parse(redirect_uri).host
|
||||
csp = request.content_security_policy
|
||||
if csp && redirect_host
|
||||
# Only modify if form_action is available and mutable
|
||||
if csp.respond_to?(:form_action) && csp.form_action.respond_to?(:<<)
|
||||
csp.form_action << "https://#{redirect_host}"
|
||||
end
|
||||
end
|
||||
rescue => e
|
||||
# Log CSP modification errors but don't fail the request
|
||||
Rails.logger.warn "OAuth: Could not modify CSP for redirect_uri #{redirect_uri}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
render :consent
|
||||
end
|
||||
|
||||
@@ -165,6 +236,8 @@ class OidcController < ApplicationController
|
||||
redirect_uri: oauth_params['redirect_uri'],
|
||||
scope: oauth_params['scope'],
|
||||
nonce: oauth_params['nonce'],
|
||||
code_challenge: oauth_params['code_challenge'],
|
||||
code_challenge_method: oauth_params['code_challenge_method'],
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
@@ -182,10 +255,17 @@ class OidcController < ApplicationController
|
||||
def token
|
||||
grant_type = params[:grant_type]
|
||||
|
||||
unless grant_type == "authorization_code"
|
||||
case grant_type
|
||||
when "authorization_code"
|
||||
handle_authorization_code_grant
|
||||
when "refresh_token"
|
||||
handle_refresh_token_grant
|
||||
else
|
||||
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
def handle_authorization_code_grant
|
||||
|
||||
# Get client credentials from Authorization header or params
|
||||
client_id, client_secret = extract_client_credentials
|
||||
@@ -205,11 +285,11 @@ class OidcController < ApplicationController
|
||||
# Get the authorization code
|
||||
code = params[:code]
|
||||
redirect_uri = params[:redirect_uri]
|
||||
code_verifier = params[:code_verifier]
|
||||
|
||||
auth_code = OidcAuthorizationCode.find_by(
|
||||
application: application,
|
||||
code: code,
|
||||
used: false
|
||||
code: code
|
||||
)
|
||||
|
||||
unless auth_code
|
||||
@@ -217,45 +297,180 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Check if code is expired
|
||||
if auth_code.expires_at < Time.current
|
||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
||||
# Use a transaction with pessimistic locking to prevent code reuse
|
||||
begin
|
||||
OidcAuthorizationCode.transaction do
|
||||
# Lock the record to prevent concurrent access
|
||||
auth_code.lock!
|
||||
|
||||
# Check if code has already been used (CRITICAL: check AFTER locking)
|
||||
if auth_code.used?
|
||||
# Per OAuth 2.0 spec, if an auth code is reused, revoke all tokens issued from it
|
||||
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
|
||||
|
||||
# Revoke all access tokens issued from this authorization code
|
||||
OidcAccessToken.where(
|
||||
application: application,
|
||||
user: auth_code.user,
|
||||
created_at: auth_code.created_at..Time.current
|
||||
).update_all(expires_at: Time.current)
|
||||
|
||||
render json: {
|
||||
error: "invalid_grant",
|
||||
error_description: "Authorization code has already been used"
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Check if code is expired
|
||||
if auth_code.expires_at < Time.current
|
||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Validate redirect URI matches
|
||||
unless auth_code.redirect_uri == redirect_uri
|
||||
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Validate PKCE if code challenge is present
|
||||
pkce_result = validate_pkce(auth_code, code_verifier)
|
||||
unless pkce_result[:valid]
|
||||
render json: {
|
||||
error: pkce_result[:error],
|
||||
error_description: pkce_result[:error_description]
|
||||
}, status: pkce_result[:status]
|
||||
return
|
||||
end
|
||||
|
||||
# Mark code as used BEFORE generating tokens (prevents reuse)
|
||||
auth_code.update!(used: true)
|
||||
|
||||
# Get the user
|
||||
user = auth_code.user
|
||||
|
||||
# Generate access token record (opaque token with BCrypt hashing)
|
||||
access_token_record = OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
scope: auth_code.scope
|
||||
)
|
||||
|
||||
# Generate refresh token (opaque, with hashing)
|
||||
refresh_token_record = OidcRefreshToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
oidc_access_token: access_token_record,
|
||||
scope: auth_code.scope
|
||||
)
|
||||
|
||||
# Generate ID token (JWT)
|
||||
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
||||
|
||||
# Return tokens
|
||||
render json: {
|
||||
access_token: access_token_record.plaintext_token, # Opaque token
|
||||
token_type: "Bearer",
|
||||
expires_in: application.access_token_ttl || 3600,
|
||||
id_token: id_token, # JWT
|
||||
refresh_token: refresh_token_record.token, # Opaque token
|
||||
scope: auth_code.scope
|
||||
}
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
def handle_refresh_token_grant
|
||||
# Get client credentials from Authorization header or params
|
||||
client_id, client_secret = extract_client_credentials
|
||||
|
||||
unless client_id && client_secret
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Validate redirect URI matches
|
||||
unless auth_code.redirect_uri == redirect_uri
|
||||
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
||||
# Find and validate the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application && application.authenticate_client_secret(client_secret)
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Mark code as used
|
||||
auth_code.update!(used: true)
|
||||
# Get the refresh token
|
||||
refresh_token = params[:refresh_token]
|
||||
unless refresh_token.present?
|
||||
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Find the refresh token record
|
||||
# Note: This is inefficient with BCrypt hashing, but necessary for security
|
||||
# In production, consider adding a token prefix for faster lookup
|
||||
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
||||
rt.token_matches?(refresh_token)
|
||||
end
|
||||
|
||||
unless refresh_token_record
|
||||
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Check if refresh token is expired
|
||||
if refresh_token_record.expired?
|
||||
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Check if refresh token is revoked
|
||||
if refresh_token_record.revoked?
|
||||
# If a revoked refresh token is used, it's a security issue
|
||||
# Revoke all tokens in the family (token rotation attack detection)
|
||||
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
||||
refresh_token_record.revoke_family!
|
||||
|
||||
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Get the user
|
||||
user = auth_code.user
|
||||
user = refresh_token_record.user
|
||||
|
||||
# Generate access token
|
||||
access_token = SecureRandom.urlsafe_base64(32)
|
||||
OidcAccessToken.create!(
|
||||
# Revoke the old refresh token (token rotation)
|
||||
refresh_token_record.revoke!
|
||||
|
||||
# Generate new access token record (opaque token with BCrypt hashing)
|
||||
new_access_token = OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
token: access_token,
|
||||
scope: auth_code.scope,
|
||||
expires_at: 1.hour.from_now
|
||||
scope: refresh_token_record.scope
|
||||
)
|
||||
|
||||
# Generate ID token
|
||||
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
||||
# Generate new refresh token (token rotation)
|
||||
new_refresh_token = OidcRefreshToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
oidc_access_token: new_access_token,
|
||||
scope: refresh_token_record.scope,
|
||||
token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking
|
||||
)
|
||||
|
||||
# Return tokens
|
||||
# Generate new ID token (JWT, no nonce for refresh grants)
|
||||
id_token = OidcJwtService.generate_id_token(user, application)
|
||||
|
||||
# Return new tokens
|
||||
render json: {
|
||||
access_token: access_token,
|
||||
access_token: new_access_token.plaintext_token, # Opaque token
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
id_token: id_token,
|
||||
scope: auth_code.scope
|
||||
expires_in: application.access_token_ttl || 3600,
|
||||
id_token: id_token, # JWT
|
||||
refresh_token: new_refresh_token.token, # Opaque token
|
||||
scope: refresh_token_record.scope
|
||||
}
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
end
|
||||
|
||||
# GET /oauth/userinfo
|
||||
@@ -267,24 +482,22 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
access_token = auth_header.sub("Bearer ", "")
|
||||
token = auth_header.sub("Bearer ", "")
|
||||
|
||||
# Find the access token
|
||||
token_record = OidcAccessToken.find_by(token: access_token)
|
||||
unless token_record
|
||||
# Find and validate access token (opaque token with BCrypt hashing)
|
||||
access_token = OidcAccessToken.find_by_token(token)
|
||||
unless access_token&.active?
|
||||
head :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Check if token is expired
|
||||
if token_record.expires_at < Time.current
|
||||
# Get the user (with fresh data from database)
|
||||
user = access_token.user
|
||||
unless user
|
||||
head :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Get the user
|
||||
user = token_record.user
|
||||
|
||||
# Return user claims
|
||||
claims = {
|
||||
sub: user.id.to_s,
|
||||
@@ -313,6 +526,73 @@ class OidcController < ApplicationController
|
||||
render json: claims
|
||||
end
|
||||
|
||||
# POST /oauth/revoke
|
||||
# RFC 7009 - Token Revocation
|
||||
def revoke
|
||||
# Get client credentials
|
||||
client_id, client_secret = extract_client_credentials
|
||||
|
||||
unless client_id && client_secret
|
||||
# RFC 7009 says we should return 200 OK even for invalid client
|
||||
# But log the attempt for security monitoring
|
||||
Rails.logger.warn "OAuth: Token revocation attempted with invalid client credentials"
|
||||
head :ok
|
||||
return
|
||||
end
|
||||
|
||||
# Find and validate the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application && application.authenticate_client_secret(client_secret)
|
||||
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
||||
head :ok
|
||||
return
|
||||
end
|
||||
|
||||
# Get the token to revoke
|
||||
token = params[:token]
|
||||
token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token"
|
||||
|
||||
unless token.present?
|
||||
# RFC 7009: Missing token parameter is an error
|
||||
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Try to find and revoke the token
|
||||
# Check token type hint first for efficiency, otherwise try both
|
||||
revoked = false
|
||||
|
||||
if token_type_hint == "refresh_token" || token_type_hint.nil?
|
||||
# Try to find as refresh token
|
||||
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
||||
rt.token_matches?(token)
|
||||
end
|
||||
|
||||
if refresh_token_record
|
||||
refresh_token_record.revoke!
|
||||
Rails.logger.info "OAuth: Refresh token revoked for application #{application.name}"
|
||||
revoked = true
|
||||
end
|
||||
end
|
||||
|
||||
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
|
||||
# Try to find as access token
|
||||
access_token_record = OidcAccessToken.where(application: application).find do |at|
|
||||
at.token_matches?(token)
|
||||
end
|
||||
|
||||
if access_token_record
|
||||
access_token_record.revoke!
|
||||
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
||||
revoked = true
|
||||
end
|
||||
end
|
||||
|
||||
# RFC 7009: Always return 200 OK, even if token was not found
|
||||
# This prevents token scanning attacks
|
||||
head :ok
|
||||
end
|
||||
|
||||
# GET /logout
|
||||
def logout
|
||||
# OpenID Connect RP-Initiated Logout
|
||||
@@ -342,6 +622,58 @@ class OidcController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def validate_pkce(auth_code, code_verifier)
|
||||
# Skip PKCE validation if no code challenge was stored (legacy clients)
|
||||
return { valid: true } unless auth_code.code_challenge.present?
|
||||
|
||||
# PKCE is required but no verifier provided
|
||||
unless code_verifier.present?
|
||||
return {
|
||||
valid: false,
|
||||
error: "invalid_request",
|
||||
error_description: "code_verifier is required when code_challenge was provided",
|
||||
status: :bad_request
|
||||
}
|
||||
end
|
||||
|
||||
# Validate code verifier format (base64url-encoded, 43-128 characters)
|
||||
unless code_verifier.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||
return {
|
||||
valid: false,
|
||||
error: "invalid_request",
|
||||
error_description: "Invalid code_verifier format. Must be 43-128 characters of base64url encoding",
|
||||
status: :bad_request
|
||||
}
|
||||
end
|
||||
|
||||
# Recreate code challenge based on method
|
||||
expected_challenge = case auth_code.code_challenge_method
|
||||
when "plain"
|
||||
code_verifier
|
||||
when "S256"
|
||||
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||
else
|
||||
return {
|
||||
valid: false,
|
||||
error: "server_error",
|
||||
error_description: "Unsupported code challenge method",
|
||||
status: :internal_server_error
|
||||
}
|
||||
end
|
||||
|
||||
# Validate the code challenge
|
||||
unless auth_code.code_challenge == expected_challenge
|
||||
return {
|
||||
valid: false,
|
||||
error: "invalid_grant",
|
||||
error_description: "Invalid code verifier",
|
||||
status: :bad_request
|
||||
}
|
||||
end
|
||||
|
||||
{ valid: true }
|
||||
end
|
||||
|
||||
def extract_client_credentials
|
||||
# Try Authorization header first (Basic auth)
|
||||
if request.headers["Authorization"]&.start_with?("Basic ")
|
||||
|
||||
@@ -19,4 +19,14 @@ module ApplicationHelper
|
||||
:smtp
|
||||
end
|
||||
end
|
||||
|
||||
def border_class_for(type)
|
||||
case type.to_s
|
||||
when 'notice' then 'border-green-200'
|
||||
when 'alert', 'error' then 'border-red-200'
|
||||
when 'warning' then 'border-yellow-200'
|
||||
when 'info' then 'border-blue-200'
|
||||
else 'border-gray-200'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
85
app/javascript/controllers/flash_controller.js
Normal file
85
app/javascript/controllers/flash_controller.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
/**
|
||||
* Manages flash message display, auto-dismissal, and user interactions
|
||||
* Supports different flash types with appropriate styling and behavior
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
autoDismiss: String, // "false" or delay in milliseconds
|
||||
type: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Auto-dismiss if enabled
|
||||
if (this.autoDismissValue && this.autoDismissValue !== "false") {
|
||||
this.scheduleAutoDismiss()
|
||||
}
|
||||
|
||||
// Smooth entrance animation
|
||||
this.element.classList.add('transition-all', 'duration-300', 'ease-out')
|
||||
this.element.style.opacity = '0'
|
||||
this.element.style.transform = 'translateY(-10px)'
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
this.element.style.opacity = '1'
|
||||
this.element.style.transform = 'translateY(0)'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the flash message with smooth animation
|
||||
*/
|
||||
dismiss() {
|
||||
// Add dismiss animation
|
||||
this.element.classList.add('transition-all', 'duration-300', 'ease-in')
|
||||
this.element.style.opacity = '0'
|
||||
this.element.style.transform = 'translateY(-10px)'
|
||||
|
||||
// Remove from DOM after animation
|
||||
setTimeout(() => {
|
||||
this.element.remove()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules auto-dismissal based on the configured delay
|
||||
*/
|
||||
scheduleAutoDismiss() {
|
||||
const delay = parseInt(this.autoDismissValue)
|
||||
if (delay > 0) {
|
||||
setTimeout(() => {
|
||||
this.dismiss()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause auto-dismissal on hover (for user reading)
|
||||
*/
|
||||
mouseEnter() {
|
||||
if (this.autoDismissTimer) {
|
||||
clearTimeout(this.autoDismissTimer)
|
||||
this.autoDismissTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume auto-dismissal when hover ends
|
||||
*/
|
||||
mouseLeave() {
|
||||
if (this.autoDismissValue && this.autoDismissValue !== "false") {
|
||||
this.scheduleAutoDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard interactions
|
||||
*/
|
||||
keydown(event) {
|
||||
if (event.key === 'Escape' || event.key === 'Enter') {
|
||||
this.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
89
app/javascript/controllers/form_errors_controller.js
Normal file
89
app/javascript/controllers/form_errors_controller.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
/**
|
||||
* Manages form error display and dismissal
|
||||
* Provides consistent error handling across all forms
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ["container"]
|
||||
|
||||
/**
|
||||
* Dismisses the error container with a smooth fade-out animation
|
||||
*/
|
||||
dismiss() {
|
||||
if (!this.hasContainerTarget) return
|
||||
|
||||
// Add transition classes
|
||||
this.containerTarget.classList.add('transition-all', 'duration-300', 'opacity-0', 'transform', 'scale-95')
|
||||
|
||||
// Remove from DOM after animation completes
|
||||
setTimeout(() => {
|
||||
this.containerTarget.remove()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows server-side validation errors after form submission
|
||||
* Auto-focuses the first error field for better accessibility
|
||||
*/
|
||||
connect() {
|
||||
// Auto-focus first error field if errors exist
|
||||
this.focusFirstErrorField()
|
||||
|
||||
// Scroll to errors if needed
|
||||
this.scrollToErrors()
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the first field with validation errors
|
||||
*/
|
||||
focusFirstErrorField() {
|
||||
if (!this.hasContainerTarget) return
|
||||
|
||||
// Find first form field with errors (look for error classes or aria-invalid)
|
||||
const form = this.element.closest('form')
|
||||
if (!form) return
|
||||
|
||||
const errorField = form.querySelector('[aria-invalid="true"], .border-red-500, .ring-red-500')
|
||||
if (errorField) {
|
||||
setTimeout(() => {
|
||||
errorField.focus()
|
||||
errorField.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls error container into view if it's not visible
|
||||
*/
|
||||
scrollToErrors() {
|
||||
if (!this.hasContainerTarget) return
|
||||
|
||||
const rect = this.containerTarget.getBoundingClientRect()
|
||||
const isInViewport = rect.top >= 0 && rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
|
||||
if (!isInViewport) {
|
||||
setTimeout(() => {
|
||||
this.containerTarget.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-dismisses success messages after a delay
|
||||
* Can be called from other controllers
|
||||
*/
|
||||
autoDismiss(delay = 5000) {
|
||||
if (!this.hasContainerTarget) return
|
||||
|
||||
setTimeout(() => {
|
||||
this.dismiss()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = "Hello World!"
|
||||
}
|
||||
}
|
||||
81
app/javascript/controllers/json_validator_controller.js
Normal file
81
app/javascript/controllers/json_validator_controller.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["textarea", "status"]
|
||||
static classes = ["valid", "invalid", "validStatus", "invalidStatus"]
|
||||
|
||||
connect() {
|
||||
this.validate()
|
||||
}
|
||||
|
||||
validate() {
|
||||
const value = this.textareaTarget.value.trim()
|
||||
|
||||
if (!value) {
|
||||
this.clearStatus()
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(value)
|
||||
this.showValid()
|
||||
return true
|
||||
} catch (error) {
|
||||
this.showInvalid(error.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
format() {
|
||||
const value = this.textareaTarget.value.trim()
|
||||
|
||||
if (!value) return
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
const formatted = JSON.stringify(parsed, null, 2)
|
||||
this.textareaTarget.value = formatted
|
||||
this.showValid()
|
||||
} catch (error) {
|
||||
this.showInvalid(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
clearStatus() {
|
||||
this.textareaTarget.classList.remove(...this.invalidClasses)
|
||||
this.textareaTarget.classList.remove(...this.validClasses)
|
||||
if (this.hasStatusTarget) {
|
||||
this.statusTarget.textContent = ""
|
||||
this.statusTarget.classList.remove(...this.validStatusClasses, ...this.invalidStatusClasses)
|
||||
}
|
||||
}
|
||||
|
||||
showValid() {
|
||||
this.textareaTarget.classList.remove(...this.invalidClasses)
|
||||
this.textareaTarget.classList.add(...this.validClasses)
|
||||
if (this.hasStatusTarget) {
|
||||
this.statusTarget.textContent = "✓ Valid JSON"
|
||||
this.statusTarget.classList.remove(...this.invalidStatusClasses)
|
||||
this.statusTarget.classList.add(...this.validStatusClasses)
|
||||
}
|
||||
}
|
||||
|
||||
showInvalid(errorMessage) {
|
||||
this.textareaTarget.classList.remove(...this.validClasses)
|
||||
this.textareaTarget.classList.add(...this.invalidClasses)
|
||||
if (this.hasStatusTarget) {
|
||||
this.statusTarget.textContent = `✗ Invalid JSON: ${errorMessage}`
|
||||
this.statusTarget.classList.remove(...this.validStatusClasses)
|
||||
this.statusTarget.classList.add(...this.invalidStatusClasses)
|
||||
}
|
||||
}
|
||||
|
||||
insertSample(event) {
|
||||
event.preventDefault()
|
||||
const sample = event.params.json || event.target.dataset.jsonSample
|
||||
if (sample) {
|
||||
this.textareaTarget.value = sample
|
||||
this.format()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["userSelect", "assignLink", "editForm"]
|
||||
|
||||
connect() {
|
||||
console.log("Role management controller connected")
|
||||
}
|
||||
|
||||
assignRole(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const link = event.currentTarget
|
||||
const roleId = link.dataset.roleId
|
||||
const select = document.getElementById(`assign-user-${roleId}`)
|
||||
|
||||
if (!select.value) {
|
||||
alert("Please select a user")
|
||||
return
|
||||
}
|
||||
|
||||
// Update the href with the selected user ID
|
||||
const originalHref = link.href
|
||||
const newHref = originalHref.replace("PLACEHOLDER", select.value)
|
||||
|
||||
// Navigate to the updated URL
|
||||
window.location.href = newHref
|
||||
}
|
||||
|
||||
toggleEdit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const roleId = event.currentTarget.dataset.roleId
|
||||
const editForm = document.getElementById(`edit-role-${roleId}`)
|
||||
|
||||
if (editForm) {
|
||||
editForm.classList.toggle("hidden")
|
||||
}
|
||||
}
|
||||
|
||||
hideEdit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const roleId = event.currentTarget.dataset.roleId
|
||||
const editForm = document.getElementById(`edit-role-${roleId}`)
|
||||
|
||||
if (editForm) {
|
||||
editForm.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
}
|
||||
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
class OidcTokenCleanupJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
# Delete expired access tokens (keep revoked ones for audit trail)
|
||||
expired_access_tokens = OidcAccessToken.where("expires_at < ?", 7.days.ago)
|
||||
deleted_count = expired_access_tokens.delete_all
|
||||
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired access tokens"
|
||||
|
||||
# Delete expired refresh tokens (keep revoked ones for audit trail)
|
||||
expired_refresh_tokens = OidcRefreshToken.where("expires_at < ?", 7.days.ago)
|
||||
deleted_count = expired_refresh_tokens.delete_all
|
||||
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired refresh tokens"
|
||||
|
||||
# Delete old revoked tokens (after 30 days for audit trail)
|
||||
old_revoked_access_tokens = OidcAccessToken.where("revoked_at < ?", 30.days.ago)
|
||||
deleted_count = old_revoked_access_tokens.delete_all
|
||||
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked access tokens"
|
||||
|
||||
old_revoked_refresh_tokens = OidcRefreshToken.where("revoked_at < ?", 30.days.ago)
|
||||
deleted_count = old_revoked_refresh_tokens.delete_all
|
||||
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked refresh tokens"
|
||||
|
||||
# Delete old used authorization codes (after 7 days)
|
||||
old_auth_codes = OidcAuthorizationCode.where("created_at < ?", 7.days.ago)
|
||||
deleted_count = old_auth_codes.delete_all
|
||||
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old authorization codes"
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com')
|
||||
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@ class Application < ApplicationRecord
|
||||
has_many :allowed_groups, through: :application_groups, source: :group
|
||||
has_many :oidc_authorization_codes, dependent: :destroy
|
||||
has_many :oidc_access_tokens, dependent: :destroy
|
||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
@@ -17,8 +18,16 @@ class Application < ApplicationRecord
|
||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
||||
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
|
||||
|
||||
# Token TTL validations (for OIDC apps)
|
||||
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days
|
||||
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||
|
||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||
normalizes :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase }
|
||||
normalizes :domain_pattern, with: ->(pattern) {
|
||||
normalized = pattern&.strip&.downcase
|
||||
normalized.blank? ? nil : normalized
|
||||
}
|
||||
|
||||
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
||||
|
||||
@@ -151,8 +160,44 @@ class Application < ApplicationRecord
|
||||
secret
|
||||
end
|
||||
|
||||
# Token TTL helper methods (for OIDC)
|
||||
def access_token_expiry
|
||||
(access_token_ttl || 3600).seconds.from_now
|
||||
end
|
||||
|
||||
def refresh_token_expiry
|
||||
(refresh_token_ttl || 2592000).seconds.from_now
|
||||
end
|
||||
|
||||
def id_token_expiry_seconds
|
||||
id_token_ttl || 3600
|
||||
end
|
||||
|
||||
# Human-readable TTL for display
|
||||
def access_token_ttl_human
|
||||
duration_to_human(access_token_ttl || 3600)
|
||||
end
|
||||
|
||||
def refresh_token_ttl_human
|
||||
duration_to_human(refresh_token_ttl || 2592000)
|
||||
end
|
||||
|
||||
def id_token_ttl_human
|
||||
duration_to_human(id_token_ttl || 3600)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def duration_to_human(seconds)
|
||||
if seconds < 3600
|
||||
"#{seconds / 60} minutes"
|
||||
elsif seconds < 86400
|
||||
"#{seconds / 3600} hours"
|
||||
else
|
||||
"#{seconds / 86400} days"
|
||||
end
|
||||
end
|
||||
|
||||
def generate_client_credentials
|
||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||
# Generate and hash the client secret
|
||||
|
||||
@@ -1,34 +1,83 @@
|
||||
class OidcAccessToken < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
validates :token, presence: true, uniqueness: true
|
||||
validates :token, uniqueness: true, presence: true
|
||||
|
||||
scope :valid, -> { where("expires_at > ?", Time.current) }
|
||||
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
scope :revoked, -> { where.not(revoked_at: nil) }
|
||||
scope :active, -> { valid }
|
||||
|
||||
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
|
||||
def revoked?
|
||||
revoked_at.present?
|
||||
end
|
||||
|
||||
def active?
|
||||
!expired?
|
||||
!expired? && !revoked?
|
||||
end
|
||||
|
||||
def revoke!
|
||||
update!(expires_at: Time.current)
|
||||
update!(revoked_at: Time.current)
|
||||
# Also revoke associated refresh tokens
|
||||
oidc_refresh_tokens.each(&:revoke!)
|
||||
end
|
||||
|
||||
# Check if a plaintext token matches the hashed token
|
||||
def token_matches?(plaintext_token)
|
||||
return false if plaintext_token.blank?
|
||||
|
||||
# Use BCrypt to compare if token_digest exists
|
||||
if token_digest.present?
|
||||
BCrypt::Password.new(token_digest) == plaintext_token
|
||||
# Fall back to direct comparison for backward compatibility
|
||||
elsif token.present?
|
||||
token == plaintext_token
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Find by token (validates and checks if revoked)
|
||||
def self.find_by_token(plaintext_token)
|
||||
return nil if plaintext_token.blank?
|
||||
|
||||
# Find all non-revoked, non-expired tokens
|
||||
valid.find_each do |access_token|
|
||||
# Use BCrypt to compare (if token_digest exists) or direct comparison
|
||||
if access_token.token_digest.present?
|
||||
return access_token if BCrypt::Password.new(access_token.token_digest) == plaintext_token
|
||||
elsif access_token.token == plaintext_token
|
||||
return access_token
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
self.token ||= SecureRandom.urlsafe_base64(48)
|
||||
return if token.present?
|
||||
|
||||
# Generate opaque access token
|
||||
plaintext = SecureRandom.urlsafe_base64(48)
|
||||
self.plaintext_token = plaintext # Store temporarily for returning to client
|
||||
self.token_digest = BCrypt::Password.create(plaintext)
|
||||
# Keep token column for backward compatibility during migration
|
||||
self.token = plaintext
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
self.expires_at ||= 1.hour.from_now
|
||||
self.expires_at ||= application.access_token_expiry
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,6 +7,8 @@ class OidcAuthorizationCode < ApplicationRecord
|
||||
|
||||
validates :code, presence: true, uniqueness: true
|
||||
validates :redirect_uri, presence: true
|
||||
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true }
|
||||
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
||||
|
||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
@@ -23,6 +25,10 @@ class OidcAuthorizationCode < ApplicationRecord
|
||||
update!(used: true)
|
||||
end
|
||||
|
||||
def uses_pkce?
|
||||
code_challenge.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_code
|
||||
@@ -32,4 +38,11 @@ class OidcAuthorizationCode < ApplicationRecord
|
||||
def set_expiry
|
||||
self.expires_at ||= 10.minutes.from_now
|
||||
end
|
||||
|
||||
def validate_code_challenge_format
|
||||
# PKCE code challenge should be base64url-encoded, 43-128 characters
|
||||
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||
errors.add(:code_challenge, "must be 43-128 characters of base64url encoding")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
87
app/models/oidc_refresh_token.rb
Normal file
87
app/models/oidc_refresh_token.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
class OidcRefreshToken < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
belongs_to :oidc_access_token
|
||||
has_many :oidc_access_tokens, foreign_key: :oidc_access_token_id, dependent: :nullify
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
before_validation :set_token_family_id, on: :create
|
||||
|
||||
validates :token_digest, presence: true, uniqueness: true
|
||||
|
||||
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
scope :revoked, -> { where.not(revoked_at: nil) }
|
||||
scope :active, -> { valid }
|
||||
|
||||
# For token rotation detection (prevents reuse attacks)
|
||||
scope :in_family, ->(family_id) { where(token_family_id: family_id) }
|
||||
|
||||
attr_accessor :token # Store plaintext token temporarily for returning to client
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
|
||||
def revoked?
|
||||
revoked_at.present?
|
||||
end
|
||||
|
||||
def active?
|
||||
!expired? && !revoked?
|
||||
end
|
||||
|
||||
def revoke!
|
||||
update!(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
# Revoke all refresh tokens in the same family (token rotation security)
|
||||
def revoke_family!
|
||||
return unless token_family_id.present?
|
||||
|
||||
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
# Verify a plaintext token against the stored digest
|
||||
def self.find_by_token(plaintext_token)
|
||||
return nil if plaintext_token.blank?
|
||||
|
||||
# Try to find tokens that could match (we can't search by hash directly)
|
||||
# This is less efficient but necessary with BCrypt
|
||||
# In production, you might want to add a token prefix or other optimization
|
||||
all.find do |refresh_token|
|
||||
refresh_token.token_matches?(plaintext_token)
|
||||
end
|
||||
end
|
||||
|
||||
def token_matches?(plaintext_token)
|
||||
return false if plaintext_token.blank? || token_digest.blank?
|
||||
|
||||
BCrypt::Password.new(token_digest) == plaintext_token
|
||||
rescue BCrypt::Errors::InvalidHash
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
# Generate a secure random token
|
||||
plaintext = SecureRandom.urlsafe_base64(48)
|
||||
self.token = plaintext # Store temporarily for returning to client
|
||||
|
||||
# Hash it with BCrypt for storage
|
||||
self.token_digest = BCrypt::Password.create(plaintext)
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
# Use application's configured refresh token TTL
|
||||
self.expires_at ||= application.refresh_token_expiry
|
||||
end
|
||||
|
||||
def set_token_family_id
|
||||
# Use a random ID to group tokens in the same rotation chain
|
||||
# This helps detect token reuse attacks
|
||||
self.token_family_id ||= SecureRandom.random_number(2**31)
|
||||
end
|
||||
end
|
||||
@@ -3,12 +3,14 @@ class OidcJwtService
|
||||
# Generate an ID token (JWT) for the user
|
||||
def generate_id_token(user, application, nonce: nil)
|
||||
now = Time.current.to_i
|
||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||
ttl = application.id_token_expiry_seconds
|
||||
|
||||
payload = {
|
||||
iss: issuer_url,
|
||||
sub: user.id.to_s,
|
||||
aud: application.client_id,
|
||||
exp: now + 3600, # 1 hour
|
||||
exp: now + ttl,
|
||||
iat: now,
|
||||
email: user.email_address,
|
||||
email_verified: true,
|
||||
@@ -63,7 +65,9 @@ class OidcJwtService
|
||||
def issuer_url
|
||||
# In production, this should come from ENV or config
|
||||
# For now, we'll use a placeholder that can be overridden
|
||||
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}"
|
||||
host = ENV.fetch("CLINCH_HOST", "localhost:3000")
|
||||
# Ensure URL has https:// protocol
|
||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form" }) do |form| %>
|
||||
<% if application.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<% application.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||
@@ -61,6 +44,53 @@
|
||||
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
||||
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
|
||||
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Range: 5 min - 24 hours
|
||||
<br>Default: 1 hour (3600s)
|
||||
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Range: 1 day - 90 days
|
||||
<br>Default: 30 days (2592000s)
|
||||
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Range: 5 min - 24 hours
|
||||
<br>Default: 1 hour (3600s)
|
||||
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
|
||||
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
|
||||
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
||||
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p>
|
||||
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
||||
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forward Auth-specific fields -->
|
||||
@@ -73,12 +103,25 @@
|
||||
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :headers_config, rows: 10, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}' %>
|
||||
<%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}',
|
||||
data: {
|
||||
action: "input->json-validator#validate blur->json-validator#format",
|
||||
json_validator_target: "textarea"
|
||||
} %>
|
||||
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||
</div>
|
||||
</div>
|
||||
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
|
||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||
<details class="mt-2">
|
||||
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
|
||||
<div class="mt-2 ml-4 space-y-1 text-xs">
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
<% case application.app_type %>
|
||||
<% when "oidc" %>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||
<% when "forward_auth" %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
|
||||
<% when "saml" %>
|
||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||
<% end %>
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
|
||||
<% if group.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<%= pluralize(group.errors.count, "error") %> prohibited this group from being saved:
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<% group.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||
@@ -49,10 +32,25 @@
|
||||
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"roles": ["admin", "editor"]}' %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
|
||||
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||
placeholder: '{"roles": ["admin", "editor"]}',
|
||||
data: {
|
||||
action: "input->json-validator#validate blur->json-validator#format",
|
||||
json_validator_target: "textarea"
|
||||
} %>
|
||||
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<p>Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
|
||||
@@ -39,9 +39,11 @@
|
||||
<%= pluralize(group.applications.count, "app") %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900" %>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
|
||||
<% if user.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<% user.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div>
|
||||
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
||||
@@ -52,10 +35,25 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"department": "engineering", "level": "senior"}' %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
||||
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||
placeholder: '{"department": "engineering", "level": "senior"}',
|
||||
data: {
|
||||
action: "input->json-validator#validate blur->json-validator#format",
|
||||
json_validator_target: "textarea"
|
||||
} %>
|
||||
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3", data: { turbo: false } do |form| %>
|
||||
<%= form_with url: "/oauth/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
|
||||
<%= form.submit "Authorize",
|
||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<h1 class="font-bold text-4xl">Forgot your password?</h1>
|
||||
|
||||
<%= form_with url: passwords_path, class: "contents" do |form| %>
|
||||
<%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||
<div class="my-5">
|
||||
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
||||
</div>
|
||||
|
||||
<%= form_with url: signin_path, class: "contents" do |form| %>
|
||||
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||
<div class="my-5">
|
||||
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
||||
|
||||
@@ -1,29 +1,73 @@
|
||||
<% if flash[:alert] %>
|
||||
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
||||
<% flash.each do |type, message| %>
|
||||
<% next if message.blank? %>
|
||||
|
||||
<% if flash[:notice] %>
|
||||
<div class="mb-4 rounded-lg bg-green-50 p-4" role="alert">
|
||||
<%
|
||||
# Map flash types to styling
|
||||
case type.to_s
|
||||
when 'notice'
|
||||
bg_class = 'bg-green-50'
|
||||
text_class = 'text-green-800'
|
||||
icon_class = 'text-green-400'
|
||||
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
|
||||
auto_dismiss = true
|
||||
when 'alert', 'error'
|
||||
bg_class = 'bg-red-50'
|
||||
text_class = 'text-red-800'
|
||||
icon_class = 'text-red-400'
|
||||
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
|
||||
auto_dismiss = false
|
||||
when 'warning'
|
||||
bg_class = 'bg-yellow-50'
|
||||
text_class = 'text-yellow-800'
|
||||
icon_class = 'text-yellow-400'
|
||||
icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
||||
auto_dismiss = false
|
||||
when 'info'
|
||||
bg_class = 'bg-blue-50'
|
||||
text_class = 'text-blue-800'
|
||||
icon_class = 'text-blue-400'
|
||||
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||
auto_dismiss = true
|
||||
else
|
||||
# Default styling for unknown types
|
||||
bg_class = 'bg-gray-50'
|
||||
text_class = 'text-gray-800'
|
||||
icon_class = 'text-gray-400'
|
||||
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||
auto_dismiss = false
|
||||
end
|
||||
%>
|
||||
|
||||
<div class="mb-4 rounded-lg <%= bg_class %> p-4 border border-opacity-20 <%= border_class_for(type) %>"
|
||||
role="alert"
|
||||
data-controller="flash"
|
||||
data-flash-auto-dismiss-value="<%= auto_dismiss ? '5000' : 'false' %>"
|
||||
data-flash-type-value="<%= type %>">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
<div class="shrink-0">
|
||||
<svg class="h-5 w-5 <%= icon_class %>" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium <%= text_class %>"><%= message %></p>
|
||||
</div>
|
||||
<% if auto_dismiss || type.to_s != 'alert' %>
|
||||
<div class="ml-auto pl-3">
|
||||
<div class="-mx-1.5 -my-1.5">
|
||||
<button type="button"
|
||||
data-action="click->flash#dismiss"
|
||||
class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-<%= bg_class.gsub('bg-', '') %>"
|
||||
aria-label="Dismiss">
|
||||
<span class="sr-only">Dismiss</span>
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
<% if form.object.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<%# Usage: render "shared/form_errors", object: @user %>
|
||||
<%# Usage: render "shared/form_errors", form: form %>
|
||||
|
||||
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
|
||||
<% if form_object&.errors&.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4 mb-6 border border-red-200" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 id="form-errors-title" class="text-sm font-medium text-red-800">
|
||||
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
<% form.object.errors.full_messages.each do |message| %>
|
||||
<div class="mt-2">
|
||||
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
|
||||
<% form_object.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto pl-3">
|
||||
<div class="-mx-1.5 -my-1.5">
|
||||
<button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" aria-label="Dismiss">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -4,17 +4,8 @@
|
||||
<p class="mt-2 text-gray-600">Create your admin account to get started</p>
|
||||
</div>
|
||||
|
||||
<%= form_with model: @user, url: signup_path, class: "contents" do |form| %>
|
||||
<% if @user.errors.any? %>
|
||||
<div class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
|
||||
<h2><%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved:</h2>
|
||||
<ul class="list-disc list-inside">
|
||||
<% @user.errors.each do |error| %>
|
||||
<li><%= error.full_message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>
|
||||
|
||||
@@ -80,14 +80,28 @@ Rails.application.configure do
|
||||
# Only use :id for inspections in production.
|
||||
config.active_record.attributes_for_inspect = [ :id ]
|
||||
|
||||
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
|
||||
def self.extract_domain(host)
|
||||
return host if host.blank?
|
||||
# Remove protocol (http:// or https://) if present
|
||||
host.gsub(/^https?:\/\//, '')
|
||||
end
|
||||
|
||||
# Helper method to ensure URL has https:// protocol
|
||||
def self.ensure_https(url)
|
||||
return url if url.blank?
|
||||
# Add https:// if no protocol is present
|
||||
url.match?(/^https?:\/\//) ? url : "https://#{url}"
|
||||
end
|
||||
|
||||
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||
# Configure allowed hosts based on deployment scenario
|
||||
allowed_hosts = [
|
||||
ENV.fetch('CLINCH_HOST', 'auth.example.com'), # External domain (auth service itself)
|
||||
extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself)
|
||||
]
|
||||
|
||||
# Use PublicSuffix to extract registrable domain and allow all subdomains
|
||||
host_domain = ENV.fetch('CLINCH_HOST', 'auth.example.com')
|
||||
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
|
||||
if host_domain.present?
|
||||
begin
|
||||
# Use PublicSuffix to properly extract the domain
|
||||
|
||||
@@ -53,5 +53,5 @@ Rails.application.configure do
|
||||
|
||||
# Disable Sentry in test environment to avoid interference with tests
|
||||
# Sentry can be explicitly enabled for integration testing if needed
|
||||
config.sentry.enabled = false
|
||||
ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] = "false"
|
||||
end
|
||||
|
||||
@@ -39,6 +39,7 @@ Rails.application.configure do
|
||||
policy.base_uri :self
|
||||
|
||||
# Form actions: Allow self for all form submissions
|
||||
# Note: OAuth redirects will be handled dynamically in the consent page
|
||||
policy.form_action :self
|
||||
|
||||
# Manifest sources: Allow self for PWA manifest
|
||||
@@ -53,9 +54,12 @@ Rails.application.configure do
|
||||
# Additional security headers for WebAuthn
|
||||
# Required for WebAuthn to work properly
|
||||
policy.require_trusted_types_for :none
|
||||
policy.report_uri = "/api/csp-violation-report"
|
||||
|
||||
# CSP reporting using report_uri (supported method)
|
||||
policy.report_uri "/api/csp-violation-report"
|
||||
end
|
||||
|
||||
|
||||
# Start with CSP in report-only mode for testing
|
||||
# Set to false after verifying everything works in production
|
||||
config.content_security_policy_report_only = Rails.env.development?
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
Rails.application.config.after_initialize do
|
||||
# Create a dedicated logger for CSP violations
|
||||
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||
csp_logger = Logger.new(csp_log_path)
|
||||
|
||||
# Rotate logs daily, keep 30 days
|
||||
csp_logger.keep = 30
|
||||
# Configure log rotation
|
||||
csp_logger = Logger.new(
|
||||
csp_log_path,
|
||||
'daily', # Rotate daily
|
||||
30 # Keep 30 old log files
|
||||
)
|
||||
|
||||
csp_logger.level = Logger::INFO
|
||||
|
||||
# Format: [TIMESTAMP] LEVEL MESSAGE
|
||||
@@ -16,8 +20,14 @@ Rails.application.config.after_initialize do
|
||||
end
|
||||
|
||||
module CspViolationLocalLogger
|
||||
def self.emit(event_data)
|
||||
csp_data = event_data[:data] || {}
|
||||
def self.emit(event)
|
||||
csp_data = event[:payload] || {}
|
||||
|
||||
# Skip logging if there's no meaningful violation data
|
||||
return if csp_data.empty? ||
|
||||
(csp_data[:violated_directive].nil? &&
|
||||
csp_data[:blocked_uri].nil? &&
|
||||
csp_data[:document_uri].nil?)
|
||||
|
||||
# Build a structured log message
|
||||
violated_directive = csp_data[:violated_directive] || "unknown"
|
||||
@@ -55,7 +65,7 @@ Rails.application.config.after_initialize do
|
||||
# Determine severity for log level
|
||||
level = determine_log_level(csp_data[:violated_directive])
|
||||
|
||||
csp_logger.log(level, log_message)
|
||||
self.csp_logger.log(level, log_message)
|
||||
|
||||
# Also log to main Rails logger for visibility
|
||||
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
||||
@@ -66,6 +76,22 @@ Rails.application.config.after_initialize do
|
||||
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
||||
end
|
||||
|
||||
def self.csp_logger
|
||||
@csp_logger ||= begin
|
||||
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||
logger = Logger.new(
|
||||
csp_log_path,
|
||||
'daily', # Rotate daily
|
||||
30 # Keep 30 old log files
|
||||
)
|
||||
logger.level = Logger::INFO
|
||||
logger.formatter = proc do |severity, datetime, progname, msg|
|
||||
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
||||
end
|
||||
logger
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.determine_log_level(violated_directive)
|
||||
@@ -83,7 +109,7 @@ Rails.application.config.after_initialize do
|
||||
end
|
||||
|
||||
# Register the local logger subscriber
|
||||
Rails.event.subscribe("csp.violation", CspViolationLocalLogger)
|
||||
Rails.event.subscribe(CspViolationLocalLogger)
|
||||
|
||||
Rails.logger.info "CSP violation local logger registered - logging to: #{csp_log_path}"
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ Rails.application.config.after_initialize do
|
||||
if defined?(Sentry) && Sentry.initialized?
|
||||
|
||||
module CspViolationSentrySubscriber
|
||||
def self.emit(event_data)
|
||||
def self.emit(event)
|
||||
# Extract relevant CSP violation data
|
||||
csp_data = event_data[:data] || {}
|
||||
csp_data = event[:payload] || {}
|
||||
|
||||
# Build a descriptive message for Sentry
|
||||
violated_directive = csp_data[:violated_directive]
|
||||
@@ -111,7 +111,7 @@ Rails.application.config.after_initialize do
|
||||
end
|
||||
|
||||
# Register the subscriber for CSP violation events
|
||||
Rails.event.subscribe("csp.violation", CspViolationSentrySubscriber)
|
||||
Rails.event.subscribe(CspViolationSentrySubscriber)
|
||||
|
||||
Rails.logger.info "CSP violation Sentry subscriber registered"
|
||||
else
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
# WebAuthn configuration for Clinch Identity Provider
|
||||
WebAuthn.configure do |config|
|
||||
# Relying Party name (displayed in authenticator prompts)
|
||||
# For development, use http://localhost to match passkey in Passwords app
|
||||
# CLINCH_HOST should include protocol (https://) for WebAuthn
|
||||
origin_host = ENV.fetch("CLINCH_HOST", "http://localhost")
|
||||
config.allowed_origins = [origin_host]
|
||||
|
||||
# Relying Party ID (must match origin domain)
|
||||
# Extract domain from origin for RP ID
|
||||
origin_uri = URI.parse(origin_host)
|
||||
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost")
|
||||
# Relying Party ID (must match origin domain without protocol)
|
||||
# Extract domain from origin for RP ID if CLINCH_RP_ID not set
|
||||
if ENV["CLINCH_RP_ID"].present?
|
||||
config.rp_id = ENV["CLINCH_RP_ID"]
|
||||
else
|
||||
# Extract registrable domain from CLINCH_HOST using PublicSuffix
|
||||
origin_uri = URI.parse(origin_host)
|
||||
if origin_uri.host
|
||||
begin
|
||||
# Use PublicSuffix to get the registrable domain (e.g., "aapamilne.com" from "auth.aapamilne.com")
|
||||
domain = PublicSuffix.parse(origin_uri.host)
|
||||
config.rp_id = domain.domain || origin_uri.host
|
||||
rescue PublicSuffix::DomainInvalid => e
|
||||
Rails.logger.warn "WebAuthn: Failed to parse domain '#{origin_uri.host}': #{e.message}, using host as fallback"
|
||||
config.rp_id = origin_uri.host
|
||||
end
|
||||
else
|
||||
Rails.logger.error "WebAuthn: Could not extract host from CLINCH_HOST '#{origin_host}'"
|
||||
config.rp_id = "localhost"
|
||||
end
|
||||
end
|
||||
|
||||
# For development, we also allow localhost with common ports and without port
|
||||
if Rails.env.development?
|
||||
|
||||
@@ -29,6 +29,7 @@ Rails.application.routes.draw do
|
||||
get "/oauth/authorize", to: "oidc#authorize"
|
||||
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||
post "/oauth/token", to: "oidc#token"
|
||||
post "/oauth/revoke", to: "oidc#revoke"
|
||||
get "/oauth/userinfo", to: "oidc#userinfo"
|
||||
get "/logout", to: "oidc#logout"
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddPkceSupportToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :oidc_authorization_codes, :code_challenge, :string
|
||||
add_column :oidc_authorization_codes, :code_challenge_method, :string
|
||||
|
||||
# Add index for code_challenge to improve query performance
|
||||
add_index :oidc_authorization_codes, :code_challenge
|
||||
end
|
||||
end
|
||||
17
db/migrate/20251109011443_fix_empty_domain_patterns.rb
Normal file
17
db/migrate/20251109011443_fix_empty_domain_patterns.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class FixEmptyDomainPatterns < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
# Convert empty string domain_patterns to NULL
|
||||
# This fixes a unique constraint issue where multiple OIDC apps
|
||||
# had empty string domain_patterns, causing uniqueness violations
|
||||
execute <<-SQL
|
||||
UPDATE applications
|
||||
SET domain_pattern = NULL
|
||||
WHERE domain_pattern = ''
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
# No need to reverse this - empty strings and NULL are functionally equivalent
|
||||
# for OIDC applications where domain_pattern is not used
|
||||
end
|
||||
end
|
||||
22
db/migrate/20251112114852_create_oidc_refresh_tokens.rb
Normal file
22
db/migrate/20251112114852_create_oidc_refresh_tokens.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :oidc_refresh_tokens do |t|
|
||||
t.string :token_digest, null: false # BCrypt hashed token
|
||||
t.references :application, null: false, foreign_key: true
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.references :oidc_access_token, null: false, foreign_key: true
|
||||
t.string :scope
|
||||
t.datetime :expires_at, null: false
|
||||
t.datetime :revoked_at
|
||||
t.integer :token_family_id # For token rotation detection
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :oidc_refresh_tokens, :token_digest, unique: true
|
||||
add_index :oidc_refresh_tokens, :expires_at
|
||||
add_index :oidc_refresh_tokens, :revoked_at
|
||||
add_index :oidc_refresh_tokens, :token_family_id
|
||||
add_index :oidc_refresh_tokens, [ :application_id, :user_id ]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddTokenDigestToOidcAccessTokens < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :oidc_access_tokens, :token_digest, :string
|
||||
add_column :oidc_access_tokens, :revoked_at, :datetime
|
||||
|
||||
add_index :oidc_access_tokens, :token_digest, unique: true
|
||||
add_index :oidc_access_tokens, :revoked_at
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddTokenExpiryToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :applications, :access_token_ttl, :integer, default: 3600 # 1 hour in seconds
|
||||
add_column :applications, :refresh_token_ttl, :integer, default: 2592000 # 30 days in seconds
|
||||
add_column :applications, :id_token_ttl, :integer, default: 3600 # 1 hour in seconds
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class MakeOidcAccessTokenTokenNullable < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
change_column_null :oidc_access_tokens, :token, true
|
||||
end
|
||||
end
|
||||
38
db/schema.rb
generated
38
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
||||
create_table "application_groups", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -22,6 +22,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
||||
end
|
||||
|
||||
create_table "applications", force: :cascade do |t|
|
||||
t.integer "access_token_ttl", default: 3600
|
||||
t.boolean "active", default: true, null: false
|
||||
t.string "app_type", null: false
|
||||
t.string "client_id"
|
||||
@@ -30,10 +31,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
||||
t.text "description"
|
||||
t.string "domain_pattern"
|
||||
t.json "headers_config", default: {}, null: false
|
||||
t.integer "id_token_ttl", default: 3600
|
||||
t.string "landing_url"
|
||||
t.text "metadata"
|
||||
t.string "name", null: false
|
||||
t.text "redirect_uris"
|
||||
t.integer "refresh_token_ttl", default: 2592000
|
||||
t.string "slug", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["active"], name: "index_applications_on_active"
|
||||
@@ -55,20 +58,26 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.datetime "revoked_at"
|
||||
t.string "scope"
|
||||
t.string "token", null: false
|
||||
t.string "token"
|
||||
t.string "token_digest"
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id"
|
||||
t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id"
|
||||
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at"
|
||||
t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at"
|
||||
t.index ["token"], name: "index_oidc_access_tokens_on_token", unique: true
|
||||
t.index ["token_digest"], name: "index_oidc_access_tokens_on_token_digest", unique: true
|
||||
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
|
||||
end
|
||||
|
||||
create_table "oidc_authorization_codes", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.string "code", null: false
|
||||
t.string "code_challenge"
|
||||
t.string "code_challenge_method"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.string "nonce"
|
||||
@@ -80,10 +89,32 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
||||
t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id"
|
||||
t.index ["application_id"], name: "index_oidc_authorization_codes_on_application_id"
|
||||
t.index ["code"], name: "index_oidc_authorization_codes_on_code", unique: true
|
||||
t.index ["code_challenge"], name: "index_oidc_authorization_codes_on_code_challenge"
|
||||
t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at"
|
||||
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
||||
end
|
||||
|
||||
create_table "oidc_refresh_tokens", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.integer "oidc_access_token_id", null: false
|
||||
t.datetime "revoked_at"
|
||||
t.string "scope"
|
||||
t.string "token_digest", null: false
|
||||
t.integer "token_family_id"
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["application_id", "user_id"], name: "index_oidc_refresh_tokens_on_application_id_and_user_id"
|
||||
t.index ["application_id"], name: "index_oidc_refresh_tokens_on_application_id"
|
||||
t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at"
|
||||
t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id"
|
||||
t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at"
|
||||
t.index ["token_digest"], name: "index_oidc_refresh_tokens_on_token_digest", unique: true
|
||||
t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id"
|
||||
t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id"
|
||||
end
|
||||
|
||||
create_table "oidc_user_consents", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -171,6 +202,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
||||
add_foreign_key "oidc_access_tokens", "users"
|
||||
add_foreign_key "oidc_authorization_codes", "applications"
|
||||
add_foreign_key "oidc_authorization_codes", "users"
|
||||
add_foreign_key "oidc_refresh_tokens", "applications"
|
||||
add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens"
|
||||
add_foreign_key "oidc_refresh_tokens", "users"
|
||||
add_foreign_key "oidc_user_consents", "applications"
|
||||
add_foreign_key "oidc_user_consents", "users"
|
||||
add_foreign_key "sessions", "users"
|
||||
|
||||
440
test/controllers/oidc_authorization_code_security_test.rb
Normal file
440
test/controllers/oidc_authorization_code_security_test.rb
Normal file
@@ -0,0 +1,440 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
def setup
|
||||
@user = User.create!(email_address: "security_test@example.com", password: "password123")
|
||||
@application = Application.create!(
|
||||
name: "Security Test App",
|
||||
slug: "security-test-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||
active: true
|
||||
)
|
||||
|
||||
# Store the plain text client secret for testing
|
||||
@client_secret = @application.client_secret_digest
|
||||
@application.generate_new_client_secret!
|
||||
@plain_client_secret = @application.client_secret
|
||||
@application.save!
|
||||
end
|
||||
|
||||
def teardown
|
||||
OidcAuthorizationCode.where(application: @application).destroy_all
|
||||
OidcAccessToken.where(application: @application).destroy_all
|
||||
@user.destroy
|
||||
@application.destroy
|
||||
end
|
||||
|
||||
# ====================
|
||||
# CRITICAL SECURITY TESTS
|
||||
# ====================
|
||||
|
||||
test "prevents authorization code reuse - sequential attempts" do
|
||||
# Create a valid authorization code
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
# First request should succeed
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
first_response = JSON.parse(@response.body)
|
||||
assert first_response.key?("access_token")
|
||||
assert first_response.key?("id_token")
|
||||
|
||||
# Second request with same code should fail
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_grant", error["error"]
|
||||
assert_match(/already been used/, error["error_description"])
|
||||
end
|
||||
|
||||
test "revokes existing tokens when authorization code is reused" do
|
||||
# Create a valid authorization code
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
# First request - get access token
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
first_response = JSON.parse(@response.body)
|
||||
first_access_token = first_response["access_token"]
|
||||
|
||||
# Verify the token works
|
||||
get "/oauth/userinfo", headers: {
|
||||
"Authorization" => "Bearer #{first_access_token}"
|
||||
}
|
||||
assert_response :success
|
||||
|
||||
# Second request with same code - should fail AND revoke first token
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
|
||||
# Verify the first token is now revoked (expired)
|
||||
get "/oauth/userinfo", headers: {
|
||||
"Authorization" => "Bearer #{first_access_token}"
|
||||
}
|
||||
assert_response :unauthorized, "First access token should be revoked after code reuse"
|
||||
end
|
||||
|
||||
test "rejects already used authorization code" do
|
||||
# Create and mark code as used
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
used: true,
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_grant", error["error"]
|
||||
assert_match(/already been used/, error["error_description"])
|
||||
end
|
||||
|
||||
test "rejects expired authorization code" do
|
||||
# Create expired code
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 5.minutes.ago
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_grant", error["error"]
|
||||
assert_match(/expired/, error["error_description"])
|
||||
end
|
||||
|
||||
test "rejects authorization code with mismatched redirect_uri" do
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://evil.com/callback" # Wrong redirect URI
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_grant", error["error"]
|
||||
assert_match(/Redirect URI mismatch/, error["error_description"])
|
||||
end
|
||||
|
||||
test "rejects non-existent authorization code" do
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: "nonexistent_code_12345",
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_grant", error["error"]
|
||||
end
|
||||
|
||||
test "rejects authorization code for different application" do
|
||||
# Create another application
|
||||
other_app = Application.create!(
|
||||
name: "Other App",
|
||||
slug: "other-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:5000/callback"].to_json,
|
||||
active: true
|
||||
)
|
||||
other_secret = other_app.client_secret
|
||||
|
||||
# Create auth code for first application
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Try to use it with different application credentials
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{other_app.client_id}:#{other_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_grant", error["error"]
|
||||
|
||||
other_app.destroy
|
||||
end
|
||||
|
||||
# ====================
|
||||
# CLIENT AUTHENTICATION TESTS
|
||||
# ====================
|
||||
|
||||
test "rejects invalid client_id in Basic auth" do
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("invalid_client_id:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :unauthorized
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_client", error["error"]
|
||||
end
|
||||
|
||||
test "rejects invalid client_secret in Basic auth" do
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret")
|
||||
}
|
||||
|
||||
assert_response :unauthorized
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_client", error["error"]
|
||||
end
|
||||
|
||||
test "accepts client credentials in POST body" do
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
client_id: @application.client_id,
|
||||
client_secret: @plain_client_secret
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(@response.body)
|
||||
assert response_body.key?("access_token")
|
||||
assert response_body.key?("id_token")
|
||||
end
|
||||
|
||||
test "rejects request with no client authentication" do
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params
|
||||
|
||||
assert_response :unauthorized
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_client", error["error"]
|
||||
end
|
||||
|
||||
# ====================
|
||||
# GRANT TYPE VALIDATION
|
||||
# ====================
|
||||
|
||||
test "rejects unsupported grant_type" do
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "password",
|
||||
username: "user",
|
||||
password: "pass"
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "unsupported_grant_type", error["error"]
|
||||
end
|
||||
|
||||
test "rejects missing grant_type" do
|
||||
post "/oauth/token", params: {
|
||||
code: "some_code",
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "unsupported_grant_type", error["error"]
|
||||
end
|
||||
|
||||
# ====================
|
||||
# TIMING ATTACK PROTECTION
|
||||
# ====================
|
||||
|
||||
test "client authentication uses constant-time comparison" do
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
# Test with completely wrong secret
|
||||
times_wrong = []
|
||||
5.times do
|
||||
start_time = Time.now.to_f
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret_xxx")
|
||||
}
|
||||
times_wrong << (Time.now.to_f - start_time)
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
# Test with almost correct secret (differs by one character)
|
||||
correct_secret = @plain_client_secret
|
||||
almost_correct = correct_secret[0..-2] + "X"
|
||||
|
||||
times_almost = []
|
||||
5.times do
|
||||
start_time = Time.now.to_f
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{almost_correct}")
|
||||
}
|
||||
times_almost << (Time.now.to_f - start_time)
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
# The timing difference should be minimal (within 50ms) if using constant-time comparison
|
||||
avg_wrong = times_wrong.sum / times_wrong.size
|
||||
avg_almost = times_almost.sum / times_almost.size
|
||||
timing_difference = (avg_wrong - avg_almost).abs
|
||||
|
||||
# This is a best-effort check - in practice, constant-time comparison is handled by bcrypt
|
||||
assert timing_difference < 0.05,
|
||||
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
|
||||
end
|
||||
end
|
||||
299
test/controllers/oidc_pkce_controller_test.rb
Normal file
299
test/controllers/oidc_pkce_controller_test.rb
Normal file
@@ -0,0 +1,299 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
def setup
|
||||
@user = User.create!(email_address: "pkce_test@example.com", password: "password123")
|
||||
@application = Application.create!(
|
||||
name: "PKCE Test App",
|
||||
slug: "pkce-test-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||
active: true
|
||||
)
|
||||
|
||||
# Sign in the user using the test helper
|
||||
sign_in_as(@user)
|
||||
end
|
||||
|
||||
def teardown
|
||||
Current.session&.destroy
|
||||
OidcAuthorizationCode.where(application: @application).destroy_all
|
||||
OidcAccessToken.where(application: @application).destroy_all
|
||||
@user.destroy
|
||||
@application.destroy
|
||||
end
|
||||
|
||||
test "discovery endpoint includes PKCE support" do
|
||||
get "/.well-known/openid-configuration"
|
||||
|
||||
assert_response :success
|
||||
config = JSON.parse(@response.body)
|
||||
|
||||
assert config.key?("code_challenge_methods_supported")
|
||||
assert_includes config["code_challenge_methods_supported"], "S256"
|
||||
assert_includes config["code_challenge_methods_supported"], "plain"
|
||||
end
|
||||
|
||||
test "authorization endpoint accepts PKCE parameters (S256)" do
|
||||
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
auth_params = {
|
||||
response_type: "code",
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
state: "test_state",
|
||||
nonce: "test_nonce",
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256"
|
||||
}
|
||||
|
||||
get "/oauth/authorize", params: auth_params
|
||||
|
||||
# Should show consent page (user is already authenticated)
|
||||
assert_response :success
|
||||
assert_match /consent/, @response.body.downcase
|
||||
end
|
||||
|
||||
test "authorization endpoint accepts PKCE parameters (plain)" do
|
||||
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
auth_params = {
|
||||
response_type: "code",
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
state: "test_state",
|
||||
nonce: "test_nonce",
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "plain"
|
||||
}
|
||||
|
||||
get "/oauth/authorize", params: auth_params
|
||||
|
||||
# Should show consent page (user is already authenticated)
|
||||
assert_response :success
|
||||
assert_match /consent/, @response.body.downcase
|
||||
end
|
||||
|
||||
test "authorization endpoint rejects invalid code_challenge_method" do
|
||||
auth_params = {
|
||||
response_type: "code",
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
code_challenge_method: "invalid_method"
|
||||
}
|
||||
|
||||
get "/oauth/authorize", params: auth_params
|
||||
|
||||
assert_response :bad_request
|
||||
assert_match(/Invalid code_challenge_method/, @response.body)
|
||||
end
|
||||
|
||||
test "authorization endpoint rejects invalid code_challenge format" do
|
||||
# Contains + character which is not base64url
|
||||
auth_params = {
|
||||
response_type: "code",
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: "invalid+challenge",
|
||||
code_challenge_method: "S256"
|
||||
}
|
||||
|
||||
get "/oauth/authorize", params: auth_params
|
||||
|
||||
assert_response :bad_request
|
||||
assert_match(/Invalid code_challenge format/, @response.body)
|
||||
end
|
||||
|
||||
test "token endpoint requires code_verifier when PKCE was used (S256)" do
|
||||
# Create authorization code with PKCE S256
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
assert_match(/code_verifier is required/, error["error_description"])
|
||||
end
|
||||
|
||||
test "token endpoint requires code_verifier when PKCE was used (plain)" do
|
||||
# Create authorization code with PKCE plain
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
code_challenge_method: "plain",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
assert_match(/code_verifier is required/, error["error_description"])
|
||||
end
|
||||
|
||||
test "token endpoint rejects invalid code_verifier (S256)" do
|
||||
# Create authorization code with PKCE S256
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
# Use a properly formatted but wrong verifier (43+ chars, base64url)
|
||||
code_verifier: "wrongverifier_with_enough_characters_base64url"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_grant", error["error"]
|
||||
assert_match(/Invalid code verifier/, error["error_description"])
|
||||
end
|
||||
|
||||
test "token endpoint accepts valid code_verifier (S256)" do
|
||||
# Generate valid PKCE pair
|
||||
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code_challenge = Digest::SHA256.base64digest(code_verifier)
|
||||
.tr("+/", "-_")
|
||||
.tr("=", "")
|
||||
|
||||
# Create authorization code with PKCE S256
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
assert tokens.key?("access_token")
|
||||
assert tokens.key?("id_token")
|
||||
assert_equal "Bearer", tokens["token_type"]
|
||||
end
|
||||
|
||||
test "token endpoint accepts valid code_verifier (plain)" do
|
||||
code_verifier = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
# Create authorization code with PKCE plain
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_verifier, # Same as verifier for plain method
|
||||
code_challenge_method: "plain",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
assert tokens.key?("access_token")
|
||||
assert tokens.key?("id_token")
|
||||
assert_equal "Bearer", tokens["token_type"]
|
||||
end
|
||||
|
||||
test "token endpoint works without PKCE (backward compatibility)" do
|
||||
# Create authorization code without PKCE
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
assert tokens.key?("access_token")
|
||||
assert tokens.key?("id_token")
|
||||
assert_equal "Bearer", tokens["token_type"]
|
||||
end
|
||||
end
|
||||
235
test/controllers/oidc_refresh_token_controller_test.rb
Normal file
235
test/controllers/oidc_refresh_token_controller_test.rb
Normal file
@@ -0,0 +1,235 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:alice)
|
||||
@application = applications(:kavita_app)
|
||||
# Store a known client secret for testing
|
||||
@client_secret = SecureRandom.urlsafe_base64(48)
|
||||
@application.client_secret = @client_secret
|
||||
@application.save!
|
||||
end
|
||||
|
||||
test "token endpoint returns refresh_token with authorization_code grant" do
|
||||
# Create an authorization code
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
scope: "openid profile email",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Exchange authorization code for tokens
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json = JSON.parse(response.body)
|
||||
|
||||
assert json["access_token"].present?
|
||||
assert json["id_token"].present?
|
||||
assert json["refresh_token"].present?
|
||||
assert_equal "Bearer", json["token_type"]
|
||||
assert_equal 3600, json["expires_in"]
|
||||
end
|
||||
|
||||
test "refresh_token grant exchanges refresh token for new tokens" do
|
||||
# Create access and refresh tokens
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
# Store the plaintext refresh token (available only during creation)
|
||||
plaintext_refresh_token = refresh_token.token
|
||||
|
||||
# Use refresh token to get new tokens
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: plaintext_refresh_token,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json = JSON.parse(response.body)
|
||||
|
||||
assert json["access_token"].present?
|
||||
assert json["id_token"].present?
|
||||
assert json["refresh_token"].present?
|
||||
assert_equal "Bearer", json["token_type"]
|
||||
|
||||
# Old refresh token should be revoked
|
||||
assert refresh_token.reload.revoked?
|
||||
end
|
||||
|
||||
test "refresh_token grant fails with expired refresh token" do
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid profile email",
|
||||
expires_at: 1.hour.ago # Expired
|
||||
)
|
||||
|
||||
plaintext_refresh_token = refresh_token.token
|
||||
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: plaintext_refresh_token,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json = JSON.parse(response.body)
|
||||
assert_equal "invalid_grant", json["error"]
|
||||
end
|
||||
|
||||
test "refresh_token grant fails with revoked refresh token" do
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
plaintext_refresh_token = refresh_token.token
|
||||
refresh_token.revoke!
|
||||
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: plaintext_refresh_token,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json = JSON.parse(response.body)
|
||||
assert_equal "invalid_grant", json["error"]
|
||||
end
|
||||
|
||||
test "token revocation endpoint revokes access tokens" do
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
plaintext_access_token = access_token.plaintext_token
|
||||
|
||||
post "/oauth/revoke", params: {
|
||||
token: plaintext_access_token,
|
||||
token_type_hint: "access_token",
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert access_token.reload.revoked?
|
||||
end
|
||||
|
||||
test "token revocation endpoint revokes refresh tokens" do
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
plaintext_refresh_token = refresh_token.token
|
||||
|
||||
post "/oauth/revoke", params: {
|
||||
token: plaintext_refresh_token,
|
||||
token_type_hint: "refresh_token",
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert refresh_token.reload.revoked?
|
||||
end
|
||||
|
||||
test "token rotation: new refresh token has same family id" do
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
old_refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
family_id = old_refresh_token.token_family_id
|
||||
plaintext_refresh_token = old_refresh_token.token
|
||||
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: plaintext_refresh_token,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
|
||||
# Find the new refresh token
|
||||
new_refresh_token = OidcRefreshToken.active.where(user: @user, application: @application).last
|
||||
assert_equal family_id, new_refresh_token.token_family_id
|
||||
end
|
||||
|
||||
test "userinfo endpoint works with hashed access token" do
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid profile email"
|
||||
)
|
||||
|
||||
plaintext_token = access_token.plaintext_token
|
||||
|
||||
get "/oauth/userinfo", headers: {
|
||||
"Authorization" => "Bearer #{plaintext_token}"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json = JSON.parse(response.body)
|
||||
assert_equal @user.id.to_s, json["sub"]
|
||||
assert_equal @user.email_address, json["email"]
|
||||
end
|
||||
end
|
||||
7
test/jobs/oidc_token_cleanup_job_test.rb
Normal file
7
test/jobs/oidc_token_cleanup_job_test.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcTokenCleanupJobTest < ActiveJob::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
177
test/models/pkce_authorization_code_test.rb
Normal file
177
test/models/pkce_authorization_code_test.rb
Normal file
@@ -0,0 +1,177 @@
|
||||
require "test_helper"
|
||||
|
||||
class PkceAuthorizationCodeTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@user = User.create!(email_address: "pkce_test@example.com", password: "password123")
|
||||
@application = Application.create!(
|
||||
name: "PKCE Test App",
|
||||
slug: "pkce-test-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||
active: true
|
||||
)
|
||||
end
|
||||
|
||||
def teardown
|
||||
# Clean up any authorization codes first to avoid foreign key constraints
|
||||
OidcAuthorizationCode.where(application: @application).destroy_all
|
||||
@user.destroy
|
||||
@application.destroy
|
||||
end
|
||||
|
||||
test "authorization code can store PKCE challenge with S256 method" do
|
||||
code_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code_challenge_method = "S256"
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
assert_equal code_challenge, auth_code.code_challenge
|
||||
assert_equal code_challenge_method, auth_code.code_challenge_method
|
||||
assert auth_code.uses_pkce?
|
||||
end
|
||||
|
||||
test "authorization code can store PKCE challenge with plain method" do
|
||||
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
code_challenge_method = "plain"
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
assert_equal code_challenge, auth_code.code_challenge
|
||||
assert_equal code_challenge_method, auth_code.code_challenge_method
|
||||
assert auth_code.uses_pkce?
|
||||
end
|
||||
|
||||
test "authorization code works without PKCE (backward compatibility)" do
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
assert_nil auth_code.code_challenge
|
||||
assert_nil auth_code.code_challenge_method
|
||||
assert_not auth_code.uses_pkce?
|
||||
end
|
||||
|
||||
test "code_challenge_method validation accepts valid methods" do
|
||||
auth_code = OidcAuthorizationCode.new(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
assert auth_code.valid?
|
||||
end
|
||||
|
||||
test "code_challenge_method validation rejects invalid methods" do
|
||||
auth_code = OidcAuthorizationCode.new(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
code_challenge_method: "invalid_method",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
assert_not auth_code.valid?
|
||||
assert_includes auth_code.errors[:code_challenge_method], "is not included in the list"
|
||||
end
|
||||
|
||||
test "code_challenge format validation accepts valid base64url" do
|
||||
# Valid base64url encoded string (43 characters, valid characters)
|
||||
valid_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
|
||||
auth_code = OidcAuthorizationCode.new(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: valid_challenge,
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
assert auth_code.valid?
|
||||
end
|
||||
|
||||
test "code_challenge format validation rejects invalid format" do
|
||||
# Invalid: contains + character (not base64url)
|
||||
invalid_challenge = "dBjftJeZ4CVP+mB92K27uhbUJU1p1r/wW1gFWFOEjXk"
|
||||
|
||||
auth_code = OidcAuthorizationCode.new(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: invalid_challenge,
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
assert_not auth_code.valid?
|
||||
assert_includes auth_code.errors[:code_challenge], "must be 43-128 characters of base64url encoding"
|
||||
end
|
||||
|
||||
test "code_challenge format validation rejects wrong length" do
|
||||
# Invalid: too short (42 characters)
|
||||
short_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjX"
|
||||
|
||||
auth_code = OidcAuthorizationCode.new(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: short_challenge,
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
assert_not auth_code.valid?
|
||||
assert_includes auth_code.errors[:code_challenge], "must be 43-128 characters of base64url encoding"
|
||||
end
|
||||
|
||||
test "code_challenge validation is skipped when no challenge present" do
|
||||
auth_code = OidcAuthorizationCode.new(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Should be valid even without code_challenge
|
||||
assert auth_code.valid?
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user