Compare commits
6 Commits
4df2eee4d9
...
feature/en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e882a4d6d1 | ||
|
|
ab0085e9c9 | ||
|
|
1ee3302319 | ||
|
|
67f28faaca | ||
|
|
33ad956508 | ||
|
|
11ec753c68 |
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 )
|
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
|
||||||
* Passkey generation and login, with detection of Passkey during login
|
* Passkey generation and login, with detection of Passkey during login
|
||||||
* Forward Auth configured and working
|
* 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
|
* Invite users by email, assign to groups
|
||||||
* Self managed password reset by email
|
* Self managed password reset by email
|
||||||
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
|
* 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)
|
#### OpenID Connect (OIDC)
|
||||||
Standard OAuth2/OIDC provider with endpoints:
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
- `/authorize` - Authorization endpoint
|
- `/authorize` - Authorization endpoint with PKCE support
|
||||||
- `/token` - Token endpoint
|
- `/token` - Token endpoint (authorization_code and refresh_token grants)
|
||||||
- `/userinfo` - User info endpoint
|
- `/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)
|
#### Trusted-Header SSO (ForwardAuth)
|
||||||
Works with reverse proxies (Caddy, Traefik, Nginx):
|
Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||||
@@ -156,25 +163,29 @@ Send emails for:
|
|||||||
- Redirect URIs (for OIDC apps)
|
- Redirect URIs (for OIDC apps)
|
||||||
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
|
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
|
||||||
- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
|
- 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)
|
- Metadata (flexible JSON storage)
|
||||||
- Active flag
|
- Active flag
|
||||||
- Many-to-many with Groups (allowlist)
|
- Many-to-many with Groups (allowlist)
|
||||||
|
|
||||||
**OIDC Tokens**
|
**OIDC Tokens**
|
||||||
- Authorization codes (10-minute expiry, one-time use)
|
- Authorization codes (10-minute expiry, one-time use, PKCE support)
|
||||||
- Access tokens (1-hour expiry, revocable)
|
- 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
|
## Authentication Flows
|
||||||
|
|
||||||
### OIDC Authorization Flow
|
### 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)
|
2. User authenticates with Clinch (username/password + optional TOTP)
|
||||||
3. Access control check: Is user in an allowed group for this app?
|
3. Access control check: Is user in an allowed group for this app?
|
||||||
4. If allowed, generate authorization code and redirect to client
|
4. If allowed, generate authorization code and redirect to client
|
||||||
5. Client exchanges code for access token at `/token`
|
5. Client exchanges code at `/token` for ID token, access token, and refresh token
|
||||||
6. Client uses access token to fetch user info from `/userinfo`
|
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
|
### ForwardAuth Flow
|
||||||
1. User requests protected resource at `https://app.example.com/dashboard`
|
1. User requests protected resource at `https://app.example.com/dashboard`
|
||||||
@@ -258,6 +269,10 @@ SMTP_ENABLE_STARTTLS=true
|
|||||||
# Application
|
# Application
|
||||||
CLINCH_HOST=https://auth.example.com
|
CLINCH_HOST=https://auth.example.com
|
||||||
CLINCH_FROM_EMAIL=noreply@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
|
### First Run
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ module Admin
|
|||||||
def application_params
|
def application_params
|
||||||
params.require(:application).permit(
|
params.require(:application).permit(
|
||||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
: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|
|
).tap do |whitelisted|
|
||||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||||
whitelisted.delete(:client_secret)
|
whitelisted.delete(:client_secret)
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ module Api
|
|||||||
report_data = JSON.parse(request.body.read)
|
report_data = JSON.parse(request.body.read)
|
||||||
csp_report = report_data['csp-report']
|
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
|
# Log the violation for security monitoring
|
||||||
Rails.logger.warn "CSP Violation Report:"
|
Rails.logger.warn "CSP Violation Report:"
|
||||||
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
||||||
|
|||||||
@@ -221,7 +221,9 @@ module Api
|
|||||||
|
|
||||||
# Try CLINCH_HOST environment variable first
|
# Try CLINCH_HOST environment variable first
|
||||||
if ENV['CLINCH_HOST'].present?
|
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
|
else
|
||||||
# Fallback to the request host
|
# Fallback to the request host
|
||||||
request_host = request.host || request.headers['X-Forwarded-Host']
|
request_host = request.host || request.headers['X-Forwarded-Host']
|
||||||
|
|||||||
@@ -120,11 +120,11 @@ module Authentication
|
|||||||
# Generate a secure random token
|
# Generate a secure random token
|
||||||
token = SecureRandom.urlsafe_base64(32)
|
token = SecureRandom.urlsafe_base64(32)
|
||||||
|
|
||||||
# Store it with an expiry of 30 seconds
|
# Store it with an expiry of 60 seconds
|
||||||
Rails.cache.write(
|
Rails.cache.write(
|
||||||
"forward_auth_token:#{token}",
|
"forward_auth_token:#{token}",
|
||||||
session_obj.id,
|
session_obj.id,
|
||||||
expires_in: 30.seconds
|
expires_in: 60.seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set the token as a query parameter on the redirect URL
|
# Set the token as a query parameter on the redirect URL
|
||||||
@@ -134,6 +134,8 @@ module Authentication
|
|||||||
original_url = controller_session[:return_to_after_authenticating]
|
original_url = controller_session[:return_to_after_authenticating]
|
||||||
uri = URI.parse(original_url)
|
uri = URI.parse(original_url)
|
||||||
|
|
||||||
|
# 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
|
# Add token as query parameter
|
||||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||||
query_params['fa_token'] = token
|
query_params['fa_token'] = token
|
||||||
@@ -144,3 +146,4 @@ module Authentication
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class OidcController < ApplicationController
|
class OidcController < ApplicationController
|
||||||
# Discovery and JWKS endpoints are public
|
# Discovery and JWKS endpoints are public
|
||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout]
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
||||||
skip_before_action :verify_authenticity_token, only: [:token, :logout]
|
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
|
||||||
|
|
||||||
# GET /.well-known/openid-configuration
|
# GET /.well-known/openid-configuration
|
||||||
def discovery
|
def discovery
|
||||||
@@ -11,11 +11,13 @@ class OidcController < ApplicationController
|
|||||||
issuer: base_url,
|
issuer: base_url,
|
||||||
authorization_endpoint: "#{base_url}/oauth/authorize",
|
authorization_endpoint: "#{base_url}/oauth/authorize",
|
||||||
token_endpoint: "#{base_url}/oauth/token",
|
token_endpoint: "#{base_url}/oauth/token",
|
||||||
|
revocation_endpoint: "#{base_url}/oauth/revoke",
|
||||||
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
||||||
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
||||||
end_session_endpoint: "#{base_url}/logout",
|
end_session_endpoint: "#{base_url}/logout",
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
response_modes_supported: ["query"],
|
response_modes_supported: ["query"],
|
||||||
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
subject_types_supported: ["public"],
|
subject_types_supported: ["public"],
|
||||||
id_token_signing_alg_values_supported: ["RS256"],
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
scopes_supported: ["openid", "profile", "email", "groups"],
|
scopes_supported: ["openid", "profile", "email", "groups"],
|
||||||
@@ -34,7 +36,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# GET /oauth/authorize
|
# GET /oauth/authorize
|
||||||
def authorize
|
def authorize
|
||||||
# Get parameters
|
# Get parameters (ignore forward auth tokens and other unknown params)
|
||||||
client_id = params[:client_id]
|
client_id = params[:client_id]
|
||||||
redirect_uri = params[:redirect_uri]
|
redirect_uri = params[:redirect_uri]
|
||||||
state = params[:state]
|
state = params[:state]
|
||||||
@@ -46,20 +48,25 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Validate required parameters
|
# Validate required parameters
|
||||||
unless client_id.present? && redirect_uri.present? && response_type == "code"
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate PKCE parameters if present
|
# Validate PKCE parameters if present
|
||||||
if code_challenge.present?
|
if code_challenge.present?
|
||||||
unless %w[plain S256].include?(code_challenge_method)
|
unless %w[plain S256].include?(code_challenge_method)
|
||||||
render plain: "Invalid code_challenge_method. Supported: plain, S256", status: :bad_request
|
render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
||||||
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
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
|
render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -67,13 +74,33 @@ class OidcController < ApplicationController
|
|||||||
# Find the application
|
# Find the application
|
||||||
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||||
unless @application
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate redirect URI
|
# Validate redirect URI
|
||||||
unless @application.parsed_redirect_uris.include?(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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -139,9 +166,29 @@ class OidcController < ApplicationController
|
|||||||
code_challenge_method: code_challenge_method
|
code_challenge_method: code_challenge_method
|
||||||
}
|
}
|
||||||
|
|
||||||
# Render consent page
|
# Render consent page with dynamic CSP for OAuth redirect
|
||||||
@redirect_uri = redirect_uri
|
@redirect_uri = redirect_uri
|
||||||
@scopes = requested_scopes
|
@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
|
render :consent
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -208,10 +255,17 @@ class OidcController < ApplicationController
|
|||||||
def token
|
def token
|
||||||
grant_type = params[:grant_type]
|
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
|
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_authorization_code_grant
|
||||||
|
|
||||||
# Get client credentials from Authorization header or params
|
# Get client credentials from Authorization header or params
|
||||||
client_id, client_secret = extract_client_credentials
|
client_id, client_secret = extract_client_credentials
|
||||||
@@ -235,8 +289,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
auth_code = OidcAuthorizationCode.find_by(
|
auth_code = OidcAuthorizationCode.find_by(
|
||||||
application: application,
|
application: application,
|
||||||
code: code,
|
code: code
|
||||||
used: false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
unless auth_code
|
unless auth_code
|
||||||
@@ -244,6 +297,31 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 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
|
# Check if code is expired
|
||||||
if auth_code.expires_at < Time.current
|
if auth_code.expires_at < Time.current
|
||||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
||||||
@@ -266,34 +344,134 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mark code as used
|
# Mark code as used BEFORE generating tokens (prevents reuse)
|
||||||
auth_code.update!(used: true)
|
auth_code.update!(used: true)
|
||||||
|
|
||||||
# Get the user
|
# Get the user
|
||||||
user = auth_code.user
|
user = auth_code.user
|
||||||
|
|
||||||
# Generate access token
|
# Generate access token record (opaque token with BCrypt hashing)
|
||||||
access_token = SecureRandom.urlsafe_base64(32)
|
access_token_record = OidcAccessToken.create!(
|
||||||
OidcAccessToken.create!(
|
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
token: access_token,
|
scope: auth_code.scope
|
||||||
scope: auth_code.scope,
|
|
||||||
expires_at: 1.hour.from_now
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate ID token
|
# 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)
|
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
||||||
|
|
||||||
# Return tokens
|
# Return tokens
|
||||||
render json: {
|
render json: {
|
||||||
access_token: access_token,
|
access_token: access_token_record.plaintext_token, # Opaque token
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
expires_in: 3600,
|
expires_in: application.access_token_ttl || 3600,
|
||||||
id_token: id_token,
|
id_token: id_token, # JWT
|
||||||
|
refresh_token: refresh_token_record.token, # Opaque token
|
||||||
scope: auth_code.scope
|
scope: auth_code.scope
|
||||||
}
|
}
|
||||||
end
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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 = refresh_token_record.user
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
scope: refresh_token_record.scope
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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: new_access_token.plaintext_token, # Opaque token
|
||||||
|
token_type: "Bearer",
|
||||||
|
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
|
# GET /oauth/userinfo
|
||||||
def userinfo
|
def userinfo
|
||||||
@@ -304,24 +482,22 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
access_token = auth_header.sub("Bearer ", "")
|
token = auth_header.sub("Bearer ", "")
|
||||||
|
|
||||||
# Find the access token
|
# Find and validate access token (opaque token with BCrypt hashing)
|
||||||
token_record = OidcAccessToken.find_by(token: access_token)
|
access_token = OidcAccessToken.find_by_token(token)
|
||||||
unless token_record
|
unless access_token&.active?
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if token is expired
|
# Get the user (with fresh data from database)
|
||||||
if token_record.expires_at < Time.current
|
user = access_token.user
|
||||||
|
unless user
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the user
|
|
||||||
user = token_record.user
|
|
||||||
|
|
||||||
# Return user claims
|
# Return user claims
|
||||||
claims = {
|
claims = {
|
||||||
sub: user.id.to_s,
|
sub: user.id.to_s,
|
||||||
@@ -350,6 +526,73 @@ class OidcController < ApplicationController
|
|||||||
render json: claims
|
render json: claims
|
||||||
end
|
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
|
# GET /logout
|
||||||
def logout
|
def logout
|
||||||
# OpenID Connect RP-Initiated Logout
|
# OpenID Connect RP-Initiated Logout
|
||||||
|
|||||||
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
|
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"
|
layout "mailer"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class Application < ApplicationRecord
|
|||||||
has_many :allowed_groups, through: :application_groups, source: :group
|
has_many :allowed_groups, through: :application_groups, source: :group
|
||||||
has_many :oidc_authorization_codes, dependent: :destroy
|
has_many :oidc_authorization_codes, dependent: :destroy
|
||||||
has_many :oidc_access_tokens, dependent: :destroy
|
has_many :oidc_access_tokens, dependent: :destroy
|
||||||
|
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
@@ -17,6 +18,11 @@ class Application < ApplicationRecord
|
|||||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
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" }
|
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 :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
normalizes :domain_pattern, with: ->(pattern) {
|
normalizes :domain_pattern, with: ->(pattern) {
|
||||||
normalized = pattern&.strip&.downcase
|
normalized = pattern&.strip&.downcase
|
||||||
@@ -154,8 +160,44 @@ class Application < ApplicationRecord
|
|||||||
secret
|
secret
|
||||||
end
|
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
|
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
|
def generate_client_credentials
|
||||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||||
# Generate and hash the client secret
|
# Generate and hash the client secret
|
||||||
|
|||||||
@@ -1,34 +1,83 @@
|
|||||||
class OidcAccessToken < ApplicationRecord
|
class OidcAccessToken < ApplicationRecord
|
||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||||
|
|
||||||
before_validation :generate_token, on: :create
|
before_validation :generate_token, on: :create
|
||||||
before_validation :set_expiry, 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 :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?
|
def expired?
|
||||||
expires_at <= Time.current
|
expires_at <= Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def revoked?
|
||||||
|
revoked_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
def active?
|
def active?
|
||||||
!expired?
|
!expired? && !revoked?
|
||||||
end
|
end
|
||||||
|
|
||||||
def revoke!
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_token
|
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
|
end
|
||||||
|
|
||||||
def set_expiry
|
def set_expiry
|
||||||
self.expires_at ||= 1.hour.from_now
|
self.expires_at ||= application.access_token_expiry
|
||||||
end
|
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
|
# Generate an ID token (JWT) for the user
|
||||||
def generate_id_token(user, application, nonce: nil)
|
def generate_id_token(user, application, nonce: nil)
|
||||||
now = Time.current.to_i
|
now = Time.current.to_i
|
||||||
|
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||||
|
ttl = application.id_token_expiry_seconds
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
iss: issuer_url,
|
iss: issuer_url,
|
||||||
sub: user.id.to_s,
|
sub: user.id.to_s,
|
||||||
aud: application.client_id,
|
aud: application.client_id,
|
||||||
exp: now + 3600, # 1 hour
|
exp: now + ttl,
|
||||||
iat: now,
|
iat: now,
|
||||||
email: user.email_address,
|
email: user.email_address,
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
@@ -63,7 +65,9 @@ class OidcJwtService
|
|||||||
def issuer_url
|
def issuer_url
|
||||||
# In production, this should come from ENV or config
|
# In production, this should come from ENV or config
|
||||||
# For now, we'll use a placeholder that can be overridden
|
# 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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -44,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" %>
|
<%= 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>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Forward Auth-specific fields -->
|
<!-- Forward Auth-specific fields -->
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
<% case application.app_type %>
|
<% case application.app_type %>
|
||||||
<% when "oidc" %>
|
<% 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>
|
<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" %>
|
<% 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>
|
<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 %>
|
<% end %>
|
||||||
|
|||||||
@@ -39,9 +39,11 @@
|
|||||||
<%= pluralize(group.applications.count, "app") %>
|
<%= pluralize(group.applications.count, "app") %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<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" %>
|
<div class="flex justify-end space-x-3">
|
||||||
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<%= link_to "View", 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" %>
|
<%= 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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",
|
<%= 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" %>
|
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" %>
|
||||||
|
|
||||||
|
|||||||
@@ -80,14 +80,28 @@ Rails.application.configure do
|
|||||||
# Only use :id for inspections in production.
|
# Only use :id for inspections in production.
|
||||||
config.active_record.attributes_for_inspect = [ :id ]
|
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.
|
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||||
# Configure allowed hosts based on deployment scenario
|
# Configure allowed hosts based on deployment scenario
|
||||||
allowed_hosts = [
|
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
|
# 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?
|
if host_domain.present?
|
||||||
begin
|
begin
|
||||||
# Use PublicSuffix to properly extract the domain
|
# Use PublicSuffix to properly extract the domain
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ Rails.application.configure do
|
|||||||
policy.base_uri :self
|
policy.base_uri :self
|
||||||
|
|
||||||
# Form actions: Allow self for all form submissions
|
# Form actions: Allow self for all form submissions
|
||||||
|
# Note: OAuth redirects will be handled dynamically in the consent page
|
||||||
policy.form_action :self
|
policy.form_action :self
|
||||||
|
|
||||||
# Manifest sources: Allow self for PWA manifest
|
# Manifest sources: Allow self for PWA manifest
|
||||||
@@ -53,9 +54,12 @@ Rails.application.configure do
|
|||||||
# Additional security headers for WebAuthn
|
# Additional security headers for WebAuthn
|
||||||
# Required for WebAuthn to work properly
|
# Required for WebAuthn to work properly
|
||||||
policy.require_trusted_types_for :none
|
policy.require_trusted_types_for :none
|
||||||
|
|
||||||
|
# CSP reporting using report_uri (supported method)
|
||||||
policy.report_uri "/api/csp-violation-report"
|
policy.report_uri "/api/csp-violation-report"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
# Start with CSP in report-only mode for testing
|
# Start with CSP in report-only mode for testing
|
||||||
# Set to false after verifying everything works in production
|
# Set to false after verifying everything works in production
|
||||||
config.content_security_policy_report_only = Rails.env.development?
|
config.content_security_policy_report_only = Rails.env.development?
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ Rails.application.config.after_initialize do
|
|||||||
def self.emit(event)
|
def self.emit(event)
|
||||||
csp_data = event[:payload] || {}
|
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
|
# Build a structured log message
|
||||||
violated_directive = csp_data[:violated_directive] || "unknown"
|
violated_directive = csp_data[:violated_directive] || "unknown"
|
||||||
blocked_uri = csp_data[:blocked_uri] || "unknown"
|
blocked_uri = csp_data[:blocked_uri] || "unknown"
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
# WebAuthn configuration for Clinch Identity Provider
|
# WebAuthn configuration for Clinch Identity Provider
|
||||||
WebAuthn.configure do |config|
|
WebAuthn.configure do |config|
|
||||||
# Relying Party name (displayed in authenticator prompts)
|
# 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")
|
origin_host = ENV.fetch("CLINCH_HOST", "http://localhost")
|
||||||
config.allowed_origins = [origin_host]
|
config.allowed_origins = [origin_host]
|
||||||
|
|
||||||
# Relying Party ID (must match origin domain)
|
# Relying Party ID (must match origin domain without protocol)
|
||||||
# Extract domain from origin for RP ID
|
# 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)
|
origin_uri = URI.parse(origin_host)
|
||||||
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost")
|
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
|
# For development, we also allow localhost with common ports and without port
|
||||||
if Rails.env.development?
|
if Rails.env.development?
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ Rails.application.routes.draw do
|
|||||||
get "/oauth/authorize", to: "oidc#authorize"
|
get "/oauth/authorize", to: "oidc#authorize"
|
||||||
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||||
post "/oauth/token", to: "oidc#token"
|
post "/oauth/token", to: "oidc#token"
|
||||||
|
post "/oauth/revoke", to: "oidc#revoke"
|
||||||
get "/oauth/userinfo", to: "oidc#userinfo"
|
get "/oauth/userinfo", to: "oidc#userinfo"
|
||||||
get "/logout", to: "oidc#logout"
|
get "/logout", to: "oidc#logout"
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
35
db/schema.rb
generated
35
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
|
ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
||||||
create_table "application_groups", force: :cascade do |t|
|
create_table "application_groups", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -22,6 +22,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "applications", force: :cascade do |t|
|
create_table "applications", force: :cascade do |t|
|
||||||
|
t.integer "access_token_ttl", default: 3600
|
||||||
t.boolean "active", default: true, null: false
|
t.boolean "active", default: true, null: false
|
||||||
t.string "app_type", null: false
|
t.string "app_type", null: false
|
||||||
t.string "client_id"
|
t.string "client_id"
|
||||||
@@ -30,10 +31,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
|
|||||||
t.text "description"
|
t.text "description"
|
||||||
t.string "domain_pattern"
|
t.string "domain_pattern"
|
||||||
t.json "headers_config", default: {}, null: false
|
t.json "headers_config", default: {}, null: false
|
||||||
|
t.integer "id_token_ttl", default: 3600
|
||||||
t.string "landing_url"
|
t.string "landing_url"
|
||||||
t.text "metadata"
|
t.text "metadata"
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.text "redirect_uris"
|
t.text "redirect_uris"
|
||||||
|
t.integer "refresh_token_ttl", default: 2592000
|
||||||
t.string "slug", null: false
|
t.string "slug", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["active"], name: "index_applications_on_active"
|
t.index ["active"], name: "index_applications_on_active"
|
||||||
@@ -55,14 +58,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
|
|||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "expires_at", null: false
|
t.datetime "expires_at", null: false
|
||||||
|
t.datetime "revoked_at"
|
||||||
t.string "scope"
|
t.string "scope"
|
||||||
t.string "token", null: false
|
t.string "token"
|
||||||
|
t.string "token_digest"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "user_id", 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", "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 ["application_id"], name: "index_oidc_access_tokens_on_application_id"
|
||||||
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at"
|
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"], 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"
|
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -87,6 +94,27 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
|
|||||||
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
||||||
end
|
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|
|
create_table "oidc_user_consents", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -174,6 +202,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
|
|||||||
add_foreign_key "oidc_access_tokens", "users"
|
add_foreign_key "oidc_access_tokens", "users"
|
||||||
add_foreign_key "oidc_authorization_codes", "applications"
|
add_foreign_key "oidc_authorization_codes", "applications"
|
||||||
add_foreign_key "oidc_authorization_codes", "users"
|
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", "applications"
|
||||||
add_foreign_key "oidc_user_consents", "users"
|
add_foreign_key "oidc_user_consents", "users"
|
||||||
add_foreign_key "sessions", "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
|
||||||
Reference in New Issue
Block a user