PKCE is now default enabled. You can now create public / no-secret apps OIDC apps

This commit is contained in:
Dan Milne
2025-12-31 09:22:18 +11:00
parent 00eca6d8b2
commit cc7beba9de
15 changed files with 456 additions and 64 deletions

View File

@@ -8,7 +8,8 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
slug: "security-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
active: true,
require_pkce: false
)
# Store the plain text client secret for testing
@@ -274,7 +275,8 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
slug: "other-app",
app_type: "oidc",
redirect_uris: ["http://localhost:5000/callback"].to_json,
active: true
active: true,
require_pkce: false
)
other_secret = other_app.client_secret

View File

@@ -318,16 +318,199 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
end
test "token endpoint works without PKCE (backward compatibility)" do
# Create an application with PKCE not required (legacy behavior)
legacy_app = Application.create!(
name: "Legacy App",
slug: "legacy-app",
app_type: "oidc",
redirect_uris: ["http://localhost:5000/callback"].to_json,
active: true,
require_pkce: false
)
legacy_app.generate_new_client_secret!
# Create consent for token endpoint
OidcUserConsent.create!(
user: @user,
application: @application,
application: legacy_app,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
# Create authorization code without PKCE
auth_code = OidcAuthorizationCode.create!(
application: legacy_app,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:5000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:5000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{legacy_app.client_id}:#{legacy_app.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
# Cleanup
OidcRefreshToken.where(application: legacy_app).delete_all
OidcAccessToken.where(application: legacy_app).delete_all
OidcAuthorizationCode.where(application: legacy_app).delete_all
OidcUserConsent.where(application: legacy_app).delete_all
legacy_app.destroy
end
# ====================
# PUBLIC CLIENT TESTS
# ====================
test "public client can authenticate with PKCE" do
# Create a public client (no client_secret)
public_app = Application.create!(
name: "Public App",
slug: "public-app",
app_type: "oidc",
redirect_uris: ["http://localhost:6000/callback"].to_json,
active: true,
is_public_client: true
)
assert public_app.public_client?
assert public_app.requires_pkce?
assert_nil public_app.client_secret_digest
# Create consent
OidcUserConsent.create!(
user: @user,
application: public_app,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
# PKCE parameters
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
# Create authorization code with PKCE
auth_code = OidcAuthorizationCode.create!(
application: public_app,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:6000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now,
code_challenge: code_challenge,
code_challenge_method: "S256"
)
# Token request with PKCE but no client_secret
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:6000/callback",
client_id: public_app.client_id,
code_verifier: code_verifier
}
post "/oauth/token", params: token_params
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
# Cleanup
OidcRefreshToken.where(application: public_app).delete_all
OidcAccessToken.where(application: public_app).delete_all
OidcAuthorizationCode.where(application: public_app).delete_all
OidcUserConsent.where(application: public_app).delete_all
public_app.destroy
end
test "public client fails without PKCE" do
# Create a public client (no client_secret)
public_app = Application.create!(
name: "Public App No PKCE",
slug: "public-app-no-pkce",
app_type: "oidc",
redirect_uris: ["http://localhost:7000/callback"].to_json,
active: true,
is_public_client: true
)
assert public_app.public_client?
assert public_app.requires_pkce?
# Create consent
OidcUserConsent.create!(
user: @user,
application: public_app,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
# Create authorization code WITHOUT PKCE
auth_code = OidcAuthorizationCode.create!(
application: public_app,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:7000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
# Token request without PKCE should fail
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:7000/callback",
client_id: public_app.client_id
}
post "/oauth/token", params: token_params
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match /PKCE is required for public clients/, error["error_description"]
# Cleanup
OidcRefreshToken.where(application: public_app).delete_all
OidcAccessToken.where(application: public_app).delete_all
OidcAuthorizationCode.where(application: public_app).delete_all
OidcUserConsent.where(application: public_app).delete_all
public_app.destroy
end
test "confidential client with require_pkce fails without PKCE" do
# The default @application has require_pkce: true (default)
assert @application.confidential_client?
assert @application.requires_pkce?
# Create consent
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-pkce-required"
)
# Create authorization code WITHOUT PKCE
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
@@ -337,6 +520,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
expires_at: 10.minutes.from_now
)
# Token request without PKCE should fail
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
@@ -347,10 +531,9 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match /PKCE is required/, error["error_description"]
end
end

View File

@@ -13,6 +13,7 @@ kavita_app:
https://kavita.example.com/signout-callback-oidc
metadata: "{}"
active: true
require_pkce: false
another_app:
name: Another App
@@ -24,6 +25,7 @@ another_app:
https://app.example.com/auth/callback
metadata: "{}"
active: true
require_pkce: false
audiobookshelf_app:
name: Audiobookshelf
@@ -35,3 +37,4 @@ audiobookshelf_app:
https://abs.example.com/auth/openid/callback
metadata: "{}"
active: true
require_pkce: false

View File

@@ -6,6 +6,15 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
@admin_user = users(:two)
@group = groups(:one)
@group2 = groups(:two)
# Create a forward_auth application for test.example.com
@test_app = Application.create!(
name: "Test App",
slug: "test-app",
app_type: "forward_auth",
domain_pattern: "test.example.com",
active: true
)
end
# Basic Authentication Flow Tests