Compare commits
5 Commits
364e6e21dd
...
e36a9a781a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e36a9a781a | ||
|
|
d036e25fef | ||
|
|
fcdd2b6de7 | ||
|
|
3939ea773f | ||
|
|
4b4afe277e |
28
README.md
28
README.md
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
7
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_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
315
docs/claude-review.md
Normal 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.*
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user