5 Commits

Author SHA1 Message Date
Dan Milne
e36a9a781a Add new claims to the discovery endpoint
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 17:27:28 +11:00
Dan Milne
d036e25fef Add auth_time, acr and azp support for OIDC claims 2025-12-31 17:07:54 +11:00
Dan Milne
fcdd2b6de7 Continue adding auth_time - need it in the refresh token too, so we can accurately create new access tokens. 2025-12-31 16:57:28 +11:00
Dan Milne
3939ea773f We already have a login_time stored - the time stamp of the Session instance creation ( created after successful login ). 2025-12-31 16:45:45 +11:00
Dan Milne
4b4afe277e Include auth_time in ID token. Switch from upsert -> find_and_create_by so we actually get sid values for consent on the creation of the record 2025-12-31 16:36:32 +11:00
14 changed files with 672 additions and 92 deletions

View File

@@ -85,6 +85,34 @@ Features:
- **Token security** - All tokens HMAC-SHA256 hashed (suitable for 256-bit random data), automatic cleanup of expired tokens - **Token security** - All tokens HMAC-SHA256 hashed (suitable for 256-bit random data), automatic cleanup of expired tokens
- **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy - **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy
**ID Token Claims** (JWT with RS256 signature):
| Claim | Description | Notes |
|-------|-------------|-------|
| Standard Claims | | |
| `iss` | Issuer (Clinch URL) | From `CLINCH_HOST` |
| `sub` | Subject (user identifier) | Pairwise SID - unique per app |
| `aud` | Audience | OAuth client_id |
| `exp` | Expiration timestamp | Configurable TTL |
| `iat` | Issued-at timestamp | Token creation time |
| `email` | User email | |
| `email_verified` | Email verification | Always `true` |
| `preferred_username` | Username/email | Fallback to email |
| `name` | Display name | User's name or email |
| `nonce` | Random value | From auth request (prevents replay) |
| **Security Claims** | | |
| `at_hash` | Access token hash | SHA-256 hash of access_token (OIDC Core §3.1.3.6) |
| `auth_time` | Authentication time | Unix timestamp of when user logged in (OIDC Core §2) |
| `acr` | Auth context class | `"1"` = password, `"2"` = 2FA/passkey (OIDC Core §2) |
| `azp` | Authorized party | OAuth client_id (OIDC Core §2) |
| Custom Claims | | |
| `groups` | User's groups | Array of group names |
| *custom* | Arbitrary key-values | From groups, users, or app-specific config |
**Authentication Context Class Reference (`acr`):**
- `"1"` - Something you know (password only)
- `"2"` - Two-factor or phishing-resistant (TOTP, backup codes, WebAuthn/passkey)
Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens. Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
#### Trusted-Header SSO (ForwardAuth) #### Trusted-Header SSO (ForwardAuth)

View File

@@ -44,9 +44,9 @@ module Authentication
final_url final_url
end end
def start_new_session_for(user) def start_new_session_for(user, acr: "1")
user.update!(last_sign_in_at: Time.current) user.update!(last_sign_in_at: Time.current)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session|
Current.session = session Current.session = session
# Extract root domain for cross-subdomain cookies (required for forward auth) # Extract root domain for cross-subdomain cookies (required for forward auth)

View File

@@ -30,7 +30,7 @@ class OidcController < ApplicationController
id_token_signing_alg_values_supported: ["RS256"], id_token_signing_alg_values_supported: ["RS256"],
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"], scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"], claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin", "auth_time", "acr", "azp", "at_hash"],
code_challenge_methods_supported: ["plain", "S256"], code_challenge_methods_supported: ["plain", "S256"],
backchannel_logout_supported: true, backchannel_logout_supported: true,
backchannel_logout_session_supported: true backchannel_logout_session_supported: true
@@ -162,6 +162,8 @@ class OidcController < ApplicationController
nonce: nonce, nonce: nonce,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
) )
@@ -245,15 +247,10 @@ class OidcController < ApplicationController
# Record user consent # Record user consent
requested_scopes = oauth_params['scope'].split(' ') requested_scopes = oauth_params['scope'].split(' ')
OidcUserConsent.upsert( consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
{ consent.scopes_granted = requested_scopes.join(' ')
user_id: user.id, consent.granted_at = Time.current
application_id: application.id, consent.save!
scopes_granted: requested_scopes.join(' '),
granted_at: Time.current
},
unique_by: [:user_id, :application_id]
)
# Generate authorization code # Generate authorization code
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
@@ -264,6 +261,8 @@ class OidcController < ApplicationController
nonce: oauth_params['nonce'], nonce: oauth_params['nonce'],
code_challenge: oauth_params['code_challenge'], code_challenge: oauth_params['code_challenge'],
code_challenge_method: oauth_params['code_challenge_method'], code_challenge_method: oauth_params['code_challenge_method'],
auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
) )
@@ -404,7 +403,9 @@ class OidcController < ApplicationController
application: application, application: application,
user: user, user: user,
oidc_access_token: access_token_record, oidc_access_token: access_token_record,
scope: auth_code.scope scope: auth_code.scope,
auth_time: auth_code.auth_time,
acr: auth_code.acr
) )
# Find user consent for this application # Find user consent for this application
@@ -416,13 +417,16 @@ class OidcController < ApplicationController
return return
end end
# Generate ID token (JWT) with pairwise SID and at_hash # Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
# auth_time and acr come from the authorization code (captured at /authorize time)
id_token = OidcJwtService.generate_id_token( id_token = OidcJwtService.generate_id_token(
user, user,
application, application,
consent: consent, consent: consent,
nonce: auth_code.nonce, nonce: auth_code.nonce,
access_token: access_token_record.plaintext_token access_token: access_token_record.plaintext_token,
auth_time: auth_code.auth_time,
acr: auth_code.acr
) )
# Return tokens # Return tokens
@@ -527,7 +531,9 @@ class OidcController < ApplicationController
user: user, user: user,
oidc_access_token: new_access_token, oidc_access_token: new_access_token,
scope: refresh_token_record.scope, scope: refresh_token_record.scope,
token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking
auth_time: refresh_token_record.auth_time, # Carry over original auth_time
acr: refresh_token_record.acr # Carry over original acr
) )
# Find user consent for this application # Find user consent for this application
@@ -539,12 +545,15 @@ class OidcController < ApplicationController
return return
end end
# Generate new ID token (JWT with pairwise SID and at_hash, no nonce for refresh grants) # Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
# auth_time and acr come from the original refresh token (carried over from initial auth)
id_token = OidcJwtService.generate_id_token( id_token = OidcJwtService.generate_id_token(
user, user,
application, application,
consent: consent, consent: consent,
access_token: new_access_token.plaintext_token access_token: new_access_token.plaintext_token,
auth_time: refresh_token_record.auth_time,
acr: refresh_token_record.acr
) )
# Return new tokens # Return new tokens

View File

@@ -71,8 +71,8 @@ class SessionsController < ApplicationController
return return
end end
# Sign in successful # Sign in successful (password only)
start_new_session_for user start_new_session_for user, acr: "1"
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
end end
@@ -101,26 +101,26 @@ class SessionsController < ApplicationController
return return
end end
# Try TOTP verification first # Try TOTP verification first (password + TOTP = 2FA)
if user.verify_totp(code) if user.verify_totp(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
# Restore redirect URL if it was preserved # Restore redirect URL if it was preserved
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end end
start_new_session_for user start_new_session_for user, acr: "2"
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
return return
end end
# Try backup code verification # Try backup code verification (password + backup code = 2FA)
if user.verify_backup_code(code) if user.verify_backup_code(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
# Restore redirect URL if it was preserved # Restore redirect URL if it was preserved
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end end
start_new_session_for user start_new_session_for user, acr: "2"
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
return return
end end
@@ -268,8 +268,8 @@ class SessionsController < ApplicationController
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url) session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
end end
# Create session # Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
start_new_session_for user start_new_session_for user, acr: "2"
render json: { render json: {
success: true, success: true,

View File

@@ -3,7 +3,7 @@ class OidcJwtService
class << self class << self
# Generate an ID token (JWT) for the user # Generate an ID token (JWT) for the user
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil) def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil)
now = Time.current.to_i now = Time.current.to_i
# Use application's configured ID token TTL (defaults to 1 hour) # Use application's configured ID token TTL (defaults to 1 hour)
ttl = application.id_token_expiry_seconds ttl = application.id_token_expiry_seconds
@@ -26,6 +26,16 @@ class OidcJwtService
# Add nonce if provided (OIDC requires this for implicit flow) # Add nonce if provided (OIDC requires this for implicit flow)
payload[:nonce] = nonce if nonce.present? payload[:nonce] = nonce if nonce.present?
# Add auth_time if provided (OIDC Core §2 - required when max_age is used)
payload[:auth_time] = auth_time if auth_time.present?
# Add acr if provided (OIDC Core §2 - authentication context class reference)
payload[:acr] = acr if acr.present?
# Add azp (authorized party) - the client_id this token was issued to
# OIDC Core §2 - required when aud has multiple values, optional but useful for single
payload[:azp] = application.client_id
# Add at_hash if access token is provided (OIDC Core spec §3.1.3.6) # Add at_hash if access token is provided (OIDC Core spec §3.1.3.6)
# at_hash = left-most 128 bits of SHA-256 hash of access token, base64url encoded # at_hash = left-most 128 bits of SHA-256 hash of access token, base64url encoded
if access_token.present? if access_token.present?

View File

@@ -0,0 +1,6 @@
class AddAuthTimeToOidcTokens < ActiveRecord::Migration[8.1]
def change
add_column :oidc_authorization_codes, :auth_time, :integer
add_column :oidc_refresh_tokens, :auth_time, :integer
end
end

View File

@@ -0,0 +1,7 @@
class AddAcrToOidcTokensAndSessions < ActiveRecord::Migration[8.1]
def change
add_column :sessions, :acr, :string
add_column :oidc_authorization_codes, :acr, :string
add_column :oidc_refresh_tokens, :acr, :string
end
end

7
db/schema.rb generated
View File

@@ -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_12_31_043838) do ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false t.bigint "blob_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -113,7 +113,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_043838) do
end end
create_table "oidc_authorization_codes", force: :cascade do |t| create_table "oidc_authorization_codes", force: :cascade do |t|
t.string "acr"
t.integer "application_id", null: false t.integer "application_id", null: false
t.integer "auth_time"
t.string "code_challenge" t.string "code_challenge"
t.string "code_challenge_method" t.string "code_challenge_method"
t.string "code_hmac", null: false t.string "code_hmac", null: false
@@ -134,7 +136,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_043838) do
end end
create_table "oidc_refresh_tokens", force: :cascade do |t| create_table "oidc_refresh_tokens", force: :cascade do |t|
t.string "acr"
t.integer "application_id", null: false t.integer "application_id", null: false
t.integer "auth_time"
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.integer "oidc_access_token_id", null: false t.integer "oidc_access_token_id", null: false
@@ -170,6 +174,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_043838) do
end end
create_table "sessions", force: :cascade do |t| create_table "sessions", force: :cascade do |t|
t.string "acr"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.string "device_name" t.string "device_name"
t.datetime "expires_at" t.datetime "expires_at"

315
docs/claude-review.md Normal file
View File

@@ -0,0 +1,315 @@
# Clinch - Independent Code Review
**Reviewer:** Claude (Anthropic)
**Review Date:** December 2024
**Codebase Version:** Commit 4f31fad
**Review Type:** Security-focused OIDC/OAuth2 correctness review with full application assessment
---
## Executive Summary
Clinch is a self-hosted identity and SSO portal built with Ruby on Rails. This review examined the complete codebase with particular focus on the OIDC/OAuth2 implementation, comparing it against production-grade reference implementations (Rodauth-OAuth, Authelia, Authentik).
**Overall Assessment: Production-Ready**
The implementation demonstrates solid security practices, proper adherence to OAuth 2.0 and OpenID Connect specifications, and comprehensive test coverage. The codebase is well-structured, readable, and maintainable.
---
## Feature Overview
### Authentication Methods
| Feature | Status | Notes |
|---------|--------|-------|
| Password Authentication | Implemented | bcrypt hashing, rate-limited |
| WebAuthn/Passkeys | Implemented | FIDO2 compliant, clone detection |
| TOTP 2FA | Implemented | With backup codes, admin enforcement |
| Session Management | Implemented | Device tracking, revocation |
### SSO Protocols
| Protocol | Status | Notes |
|----------|--------|-------|
| OpenID Connect | Implemented | Full OIDC Core compliance |
| OAuth 2.0 | Implemented | Authorization Code + Refresh Token grants |
| ForwardAuth | Implemented | Traefik/Caddy/nginx compatible |
### User & Access Management
| Feature | Status | Notes |
|---------|--------|-------|
| User CRUD | Implemented | Invitation flow, status management |
| Group Management | Implemented | With custom claims |
| Application Management | Implemented | OIDC + ForwardAuth types |
| Group-based Access Control | Implemented | Per-application restrictions |
---
## OIDC/OAuth2 Implementation Review
### Specification Compliance
| Specification | Status | Evidence |
|---------------|--------|----------|
| RFC 6749 (OAuth 2.0) | Compliant | Proper auth code flow, client authentication |
| RFC 7636 (PKCE) | Compliant | S256 and plain methods, enforced for public clients |
| RFC 7009 (Token Revocation) | Compliant | Always returns 200 OK, prevents scanning |
| OpenID Connect Core 1.0 | Compliant | All required claims, proper JWT structure |
| OIDC Discovery | Compliant | `.well-known/openid-configuration` |
| OIDC Back-Channel Logout | Compliant | Logout tokens per spec |
### ID Token Claims
The implementation includes all required and recommended OIDC claims:
```
Standard: iss, sub, aud, exp, iat, nonce
Profile: email, email_verified, preferred_username, name
Security: at_hash, auth_time, acr, azp
Custom: groups, plus arbitrary claims from groups/users/apps
```
### Token Security
| Aspect | Implementation | Assessment |
|--------|----------------|------------|
| Authorization Codes | HMAC-SHA256 hashed, 10-min expiry, single-use | Secure |
| Access Tokens | HMAC-SHA256 hashed, configurable TTL, indexed lookup | Secure |
| Refresh Tokens | HMAC-SHA256 hashed, rotation with family tracking | Secure |
| ID Tokens | RS256 signed JWTs | Secure |
### Security Features
1. **Authorization Code Reuse Prevention**
- Pessimistic database locking prevents race conditions
- Code reuse triggers revocation of all tokens from that code
- Location: `oidc_controller.rb:342-364`
2. **Refresh Token Rotation**
- Old refresh tokens revoked on use
- Token family tracking detects stolen token reuse
- Revoked token reuse triggers family-wide revocation
- Location: `oidc_controller.rb:504-513`
3. **PKCE Enforcement**
- Required for all public clients
- Configurable for confidential clients
- Proper S256 challenge verification
- Location: `oidc_controller.rb:749-814`
4. **Pairwise Subject Identifiers**
- Each user gets a unique `sub` per application
- Prevents cross-application user tracking
- Location: `oidc_user_consent.rb:59-61`
---
## Security Assessment
### Strengths
1. **Token Storage Architecture**
- All tokens (auth codes, access, refresh) are HMAC-hashed before storage
- Database compromise does not reveal usable tokens
- O(1) indexed lookup via HMAC (not O(n) iteration)
2. **Rate Limiting**
- Sign-in: 20/3min
- TOTP verification: 10/3min
- Token endpoint: 60/min
- Authorization: 30/min
- WebAuthn enumeration check: 10/min
3. **WebAuthn Implementation**
- Sign count validation (clone detection)
- Backup eligibility tracking
- Platform vs roaming authenticator distinction
- Credential enumeration prevention
4. **TOTP Implementation**
- Encrypted secret storage (ActiveRecord Encryption)
- Backup codes are bcrypt-hashed and single-use
- Admin can enforce TOTP requirement per user
5. **Session Security**
- ACR (Authentication Context Class Reference) tracking
- `acr: "1"` for password-only, `acr: "2"` for 2FA/passkey
- Session activity timestamps
- Remote session revocation
### Attack Mitigations
| Attack Vector | Mitigation |
|---------------|------------|
| Credential Stuffing | Rate limiting, account lockout via status |
| Token Theft | HMAC storage, short-lived access tokens, rotation |
| Session Hijacking | Secure cookies, session binding |
| CSRF | Rails CSRF protection, state parameter validation |
| Open Redirect | Strict redirect_uri validation against registered URIs |
| Authorization Code Injection | PKCE enforcement, redirect_uri binding |
| Refresh Token Replay | Token rotation, family-based revocation |
| User Enumeration | Constant-time responses, rate limiting |
### Areas Reviewed (No Issues Found)
- Redirect URI validation (exact match required)
- Client authentication (bcrypt for secrets)
- Error response handling (no sensitive data leakage in production)
- Logout implementation (backchannel notifications, session cleanup)
- Custom claims handling (reserved claim protection)
---
## Code Quality Assessment
### Architecture
| Aspect | Assessment |
|--------|------------|
| Controller Structure | Clean separation, ~900 lines for OIDC (acceptable) |
| Model Design | Well-normalized, proper associations |
| Service Objects | Used appropriately (OidcJwtService, ClaimsMerger) |
| Concerns | TokenPrefixable for shared hashing logic |
### Code Metrics
```
Controllers: ~1,500 lines
Models: ~1,500 lines
Services: ~200 lines
Total App Code: ~3,100 lines
Test Files: 36 files
```
### Readability
- Clear method naming
- Inline documentation for complex logic
- Consistent Ruby style
- No deeply nested conditionals
---
## Test Coverage
### Test Statistics
```
Total Tests: 341
Assertions: 1,349
Failures: 0
Errors: 0
Run Time: 23.5 seconds (parallel)
```
### Test Categories
| Category | Files | Coverage |
|----------|-------|----------|
| OIDC Security | 2 | Auth code reuse, token rotation, PKCE |
| Integration | 4 | WebAuthn, sessions, invitations, forward auth |
| Controllers | 8 | All major endpoints |
| Models | 10 | Validations, associations, business logic |
| Jobs | 4 | Mailers, token cleanup |
### Security-Specific Tests
The test suite includes dedicated security tests:
- `oidc_authorization_code_security_test.rb` - Code reuse, timing attacks, client auth
- `oidc_pkce_controller_test.rb` - PKCE flow validation
- `webauthn_credential_enumeration_test.rb` - Enumeration prevention
- `session_security_test.rb` - Session handling
- `totp_security_test.rb` - 2FA bypass prevention
- `input_validation_test.rb` - Input sanitization
---
## Comparison with Reference Implementations
### vs. Rodauth-OAuth (OpenID Certified)
| Aspect | Rodauth | Clinch |
|--------|---------|--------|
| Modularity | 46 feature modules | Monolithic controller |
| Token Storage | Optional hashing | HMAC-SHA256 (always) |
| PKCE | Dedicated feature | Integrated |
| Certification | OpenID Certified | Not certified |
Clinch has comparable security but less modularity.
### vs. Authelia (Production-Grade Go)
| Aspect | Authelia | Clinch |
|--------|----------|--------|
| PKCE Config | `always/public/never` | Per-app toggle |
| Key Rotation | Supported | Single key |
| PAR Support | Yes | No |
| DPoP Support | Yes | No |
Clinch lacks some advanced features but covers core use cases.
### vs. Authentik (Enterprise Python)
| Aspect | Authentik | Clinch |
|--------|-----------|--------|
| Scale | Enterprise/distributed | Single instance |
| Protocols | OAuth, SAML, LDAP, RADIUS | OAuth/OIDC, ForwardAuth |
| Complexity | High | Low |
Clinch is intentionally simpler for self-hosting.
---
## Recommendations
### Implemented During Review
The following issues were identified and fixed during this review:
1. **Token lookup performance** - Changed from O(n) BCrypt iteration to O(1) HMAC lookup
2. **`at_hash` claim** - Added per OIDC Core spec
3. **`auth_time` claim** - Added for authentication timestamp
4. **`acr` claim** - Added for authentication context class
5. **`azp` claim** - Added for authorized party
6. **Authorization code hashing** - Changed from plaintext to HMAC
7. **Consent SID preservation** - Fixed to preserve pairwise subject ID
8. **Discovery metadata** - Fixed `subject_types_supported` to `["pairwise"]`
### Optional Future Enhancements
| Enhancement | Priority | Effort |
|-------------|----------|--------|
| Key Rotation (multi-key JWKS) | Medium | Medium |
| Token Introspection (RFC 7662) | Low | Low |
| PAR (RFC 9126) | Low | Medium |
| OpenID Certification | Low | High |
---
## Conclusion
Clinch provides a solid, security-conscious OIDC/OAuth2 implementation suitable for self-hosted identity management. The codebase demonstrates:
- **Correct protocol implementation** - Follows OAuth 2.0 and OIDC specifications
- **Defense in depth** - Multiple layers of security controls
- **Modern authentication** - WebAuthn/passkeys, TOTP, proper session management
- **Maintainable code** - Clear structure, good test coverage
The implementation is appropriate for its intended use case: a lightweight, self-hosted alternative to complex enterprise identity solutions.
---
## Methodology
This review was conducted by examining:
1. All OIDC-related controllers, models, and services
2. Reference implementations (Rodauth-OAuth, Authelia, Authentik) in `tmp/`
3. Test files and coverage
4. Database schema and migrations
5. Security-critical code paths
Tools used: Static analysis, code reading, test execution, comparison with OpenID-certified implementations.
---
*This review was conducted by Claude (Anthropic) at the request of the project maintainer. The reviewer has no financial interest in the project.*

View File

@@ -47,7 +47,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -55,7 +54,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -94,7 +93,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -102,7 +100,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -149,7 +147,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
used: true, used: true,
@@ -158,7 +155,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -186,7 +183,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 5.minutes.ago expires_at: 5.minutes.ago
@@ -194,7 +190,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -221,7 +217,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -229,7 +224,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://evil.com/callback" # Wrong redirect URI redirect_uri: "http://evil.com/callback" # Wrong redirect URI
} }
@@ -284,7 +279,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -293,7 +287,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# Try to use it with different application credentials # Try to use it with different application credentials
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -325,7 +319,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -333,7 +326,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -359,7 +352,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -367,7 +359,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -393,7 +385,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -401,7 +392,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
client_id: @application.client_id, client_id: @application.client_id,
client_secret: @plain_client_secret client_secret: @plain_client_secret
@@ -428,7 +419,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -436,7 +426,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -495,7 +485,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -503,7 +492,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -616,7 +605,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
nonce: "test_nonce_123", nonce: "test_nonce_123",
@@ -626,7 +614,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# Exchange code for tokens # Exchange code for tokens
post "/oauth/token", params: { post "/oauth/token", params: {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
}, headers: { }, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
@@ -660,7 +648,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -669,7 +656,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# Exchange code for tokens # Exchange code for tokens
post "/oauth/token", params: { post "/oauth/token", params: {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
}, headers: { }, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
@@ -705,7 +692,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: code_challenge, code_challenge: code_challenge,
@@ -716,7 +702,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# Try to exchange code without code_verifier # Try to exchange code without code_verifier
post "/oauth/token", params: { post "/oauth/token", params: {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
}, headers: { }, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
@@ -745,7 +731,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: code_challenge, code_challenge: code_challenge,
@@ -756,7 +741,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# Exchange code with correct code_verifier # Exchange code with correct code_verifier
post "/oauth/token", params: { post "/oauth/token", params: {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier code_verifier: code_verifier
}, headers: { }, headers: {
@@ -785,7 +770,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: code_challenge, code_challenge: code_challenge,
@@ -796,7 +780,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# Try with wrong code_verifier # Try with wrong code_verifier
post "/oauth/token", params: { post "/oauth/token", params: {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
code_verifier: "wrong_code_verifier_12345678901234567890" code_verifier: "wrong_code_verifier_12345678901234567890"
}, headers: { }, headers: {
@@ -855,9 +839,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
assert_not_equal old_refresh_token, new_refresh_token assert_not_equal old_refresh_token, new_refresh_token
# Verify token family is preserved # Verify token family is preserved
new_token_record = OidcRefreshToken.where(application: @application).find do |rt| new_token_record = OidcRefreshToken.find_by_token(new_refresh_token)
rt.token_matches?(new_refresh_token)
end
assert_equal original_token_family_id, new_token_record.token_family_id assert_equal original_token_family_id, new_token_record.token_family_id
# Old refresh token should be revoked # Old refresh token should be revoked

View File

@@ -127,7 +127,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
@@ -137,7 +136,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -165,7 +164,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
@@ -175,7 +173,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -203,7 +201,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
@@ -213,7 +210,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
# Use a properly formatted but wrong verifier (43+ chars, base64url) # Use a properly formatted but wrong verifier (43+ chars, base64url)
code_verifier: "wrongverifier_with_enough_characters_base64url" code_verifier: "wrongverifier_with_enough_characters_base64url"
@@ -249,7 +246,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: code_challenge, code_challenge: code_challenge,
@@ -259,7 +255,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier code_verifier: code_verifier
} }
@@ -291,7 +287,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: code_verifier, # Same as verifier for plain method code_challenge: code_verifier, # Same as verifier for plain method
@@ -301,7 +296,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier code_verifier: code_verifier
} }
@@ -342,7 +337,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: legacy_app, application: legacy_app,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:5000/callback", redirect_uri: "http://localhost:5000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -350,7 +344,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:5000/callback" redirect_uri: "http://localhost:5000/callback"
} }
@@ -408,7 +402,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: public_app, application: public_app,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:6000/callback", redirect_uri: "http://localhost:6000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now, expires_at: 10.minutes.from_now,
@@ -419,7 +412,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
# Token request with PKCE but no client_secret # Token request with PKCE but no client_secret
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:6000/callback", redirect_uri: "http://localhost:6000/callback",
client_id: public_app.client_id, client_id: public_app.client_id,
code_verifier: code_verifier code_verifier: code_verifier
@@ -467,7 +460,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: public_app, application: public_app,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:7000/callback", redirect_uri: "http://localhost:7000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -476,7 +468,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
# Token request without PKCE should fail # Token request without PKCE should fail
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:7000/callback", redirect_uri: "http://localhost:7000/callback",
client_id: public_app.client_id client_id: public_app.client_id
} }
@@ -514,7 +506,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -523,7 +514,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
# Token request without PKCE should fail # Token request without PKCE should fail
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -536,4 +527,174 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
assert_equal "invalid_request", error["error"] assert_equal "invalid_request", error["error"]
assert_match /PKCE is required/, error["error_description"] assert_match /PKCE is required/, error["error_description"]
end end
# ====================
# AUTH_TIME CLAIM TESTS
# ====================
test "ID token includes auth_time claim from authorization code" do
# Create consent
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-auth-time"
)
# Generate valid PKCE pair
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = Digest::SHA256.base64digest(code_verifier)
.tr("+/", "-_")
.tr("=", "")
# Get the expected auth_time from the session's created_at
expected_auth_time = Current.session.created_at.to_i
# Create authorization code with auth_time
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: "S256",
auth_time: expected_auth_time,
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.plaintext_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?("id_token")
# Decode and verify auth_time is present and matches what we stored
decoded = JWT.decode(tokens["id_token"], nil, false).first
assert_includes decoded.keys, "auth_time", "ID token should include auth_time"
assert_equal expected_auth_time, decoded["auth_time"], "auth_time should match authorization code"
end
test "ID token includes auth_time in refresh token flow" do
# Create consent
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile offline_access",
granted_at: Time.current,
sid: "test-sid-refresh-auth-time"
)
# Get the expected auth_time from the session's created_at
expected_auth_time = Current.session.created_at.to_i
# Create initial access and refresh tokens with auth_time
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile offline_access",
code_challenge: nil,
code_challenge_method: nil,
auth_time: expected_auth_time,
expires_at: 10.minutes.from_now
)
# Update application to not require PKCE for testing
@application.update!(require_pkce: false)
token_params = {
grant_type: "authorization_code",
code: auth_code.plaintext_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)
refresh_token = tokens["refresh_token"]
# Now use the refresh token
refresh_params = {
grant_type: "refresh_token",
refresh_token: refresh_token
}
post "/oauth/token", params: refresh_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
new_tokens = JSON.parse(@response.body)
assert new_tokens.key?("id_token")
# Decode and verify auth_time is preserved from original authorization
decoded = JWT.decode(new_tokens["id_token"], nil, false).first
assert_includes decoded.keys, "auth_time", "Refreshed ID token should include auth_time"
assert_equal expected_auth_time, decoded["auth_time"], "auth_time should match original authorization code"
end
test "at_hash is correctly computed and included in ID token" do
# Create consent
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-at-hash"
)
# Generate valid PKCE pair
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = Digest::SHA256.base64digest(code_verifier)
.tr("+/", "-_")
.tr("=", "")
# Create authorization code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
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.plaintext_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)
access_token = tokens["access_token"]
id_token = tokens["id_token"]
# Decode ID token
decoded = JWT.decode(id_token, nil, false).first
assert_includes decoded.keys, "at_hash", "ID token should include at_hash"
# Verify at_hash matches the access token hash
expected_hash = Base64.urlsafe_encode64(Digest::SHA256.digest(access_token)[0..15], padding: false)
assert_equal expected_hash, decoded["at_hash"], "at_hash should match SHA-256 hash of access token"
end
end end

View File

@@ -15,7 +15,6 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: @application.parsed_redirect_uris.first, redirect_uri: @application.parsed_redirect_uris.first,
scope: "openid profile email", scope: "openid profile email",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -24,7 +23,7 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
# Exchange authorization code for tokens # Exchange authorization code for tokens
post "/oauth/token", params: { post "/oauth/token", params: {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: @application.parsed_redirect_uris.first, redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id, client_id: @application.client_id,
client_secret: @client_secret client_secret: @client_secret

View File

@@ -26,7 +26,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: code_challenge, code_challenge: code_challenge,
@@ -46,7 +45,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: code_challenge, code_challenge: code_challenge,
@@ -63,7 +61,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -78,7 +75,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase
auth_code = OidcAuthorizationCode.new( auth_code = OidcAuthorizationCode.new(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
@@ -93,7 +89,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase
auth_code = OidcAuthorizationCode.new( auth_code = OidcAuthorizationCode.new(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
@@ -112,7 +107,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase
auth_code = OidcAuthorizationCode.new( auth_code = OidcAuthorizationCode.new(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: valid_challenge, code_challenge: valid_challenge,
@@ -130,7 +124,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase
auth_code = OidcAuthorizationCode.new( auth_code = OidcAuthorizationCode.new(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: invalid_challenge, code_challenge: invalid_challenge,
@@ -149,7 +142,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase
auth_code = OidcAuthorizationCode.new( auth_code = OidcAuthorizationCode.new(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: short_challenge, code_challenge: short_challenge,
@@ -165,7 +157,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase
auth_code = OidcAuthorizationCode.new( auth_code = OidcAuthorizationCode.new(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now

View File

@@ -495,4 +495,71 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
decoded = JWT.decode(token, nil, false).first decoded = JWT.decode(token, nil, false).first
refute_includes decoded.keys, "at_hash", "Should not include at_hash when no access token" refute_includes decoded.keys, "at_hash", "Should not include at_hash when no access token"
end end
test "should include auth_time when provided" do
auth_time = Time.now.to_i - 300 # 5 minutes ago
token = @service.generate_id_token(@user, @application, auth_time: auth_time)
decoded = JWT.decode(token, nil, false).first
assert_includes decoded.keys, "auth_time", "Should include auth_time claim"
assert_equal auth_time, decoded["auth_time"], "auth_time should match provided value"
end
test "should not include auth_time when not provided" do
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
refute_includes decoded.keys, "auth_time", "Should not include auth_time when not provided"
end
test "auth_time should be included in both authorization code and refresh token flows" do
auth_time = Time.now.to_i - 600 # 10 minutes ago
access_token = "test-access-token"
# Authorization code flow (with nonce)
token_with_auth_code = @service.generate_id_token(
@user,
@application,
nonce: "test-nonce",
access_token: access_token,
auth_time: auth_time
)
# Refresh token flow (no nonce)
token_with_refresh = @service.generate_id_token(
@user,
@application,
access_token: access_token,
auth_time: auth_time
)
decoded_auth_code = JWT.decode(token_with_auth_code, nil, false).first
decoded_refresh = JWT.decode(token_with_refresh, nil, false).first
assert_equal auth_time, decoded_auth_code["auth_time"], "auth_time should be in authorization code flow"
assert_equal auth_time, decoded_refresh["auth_time"], "auth_time should be in refresh token flow"
end
test "should include acr when provided" do
token = @service.generate_id_token(@user, @application, acr: "2")
decoded = JWT.decode(token, nil, false).first
assert_includes decoded.keys, "acr", "Should include acr claim"
assert_equal "2", decoded["acr"], "acr should match provided value"
end
test "should not include acr when not provided" do
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
refute_includes decoded.keys, "acr", "Should not include acr when not provided"
end
test "should include azp (authorized party) with client_id" do
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
assert_includes decoded.keys, "azp", "Should include azp claim"
assert_equal @application.client_id, decoded["azp"], "azp should be the application's client_id"
end
end end