diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc475e9..e29da84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,14 +12,14 @@ jobs: fail-fast: false matrix: version: - - '1.0' + - '1.10' # minimum supported Julia (required by HTTP v2 / Reseau) - '1' # automatically expands to the latest stable 1.x release of Julia - nightly os: - ubuntu-latest arch: + # Reseau (HTTP v2 TLS backend) does not support 32-bit; x64 only. - x64 - - x86 include: # test macOS and Windows with latest Julia only - os: macOS-latest @@ -28,9 +28,6 @@ jobs: - os: windows-latest arch: x64 version: 1 - - os: windows-latest - arch: x86 - version: 1 steps: - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@v2 diff --git a/Project.toml b/Project.toml index e5ddc09..ac896da 100644 --- a/Project.toml +++ b/Project.toml @@ -1,31 +1,32 @@ name = "OpenIDConnect" uuid = "195a77af-17c0-520f-ac58-3336c7ea8576" keywords = ["openidconnect", "openid-connect", "openid", "openidc", "julia", "identity"] -authors = ["Tanmay Mohapatra "] license = "MIT" desc = "OpenID Connect for Julia" -version = "0.1.8" +version = "0.2.0" +authors = ["Tanmay Mohapatra "] [deps] Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" JWTs = "d850fbd6-035d-5a70-a269-1ca2e636ac6c" -MbedTLS = "739be429-bea8-5141-9913-cc70e7f3736d" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +Reseau = "802f3686-a58f-41ce-bb0c-3c43c75bba36" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" [compat] -julia = "1" -HTTP = "0.8, 0.9, 1" +HTTP = "2.1.1" JSON = "0.21, 1" JWTs = "0.1, 0.2, 0.3" -MbedTLS = "0.6.8, 0.7, 1" +Reseau = "1.2.1" SHA = "<0.0.1, 0.7, 1" +julia = "1.10" [extras] +NetworkOptions = "ca575930-c2e3-43a9-ace4-1e988b2c1908" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test", "Random"] +test = ["Test", "Random", "NetworkOptions"] diff --git a/README.md b/README.md index 7b8dbcb..117b124 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ function OIDCCtx( client_secret::String, scopes::Vector{String}=DEFAULT_SCOPES; verify::Union{Nothing,Bool}=nothing, - cacrt::Union{Nothing,String,MbedTLS.CRT}=nothing, + cacrt::Union{Nothing,String}=nothing, state_timeout_secs::Int=DEFAULT_STATE_TIMEOUT_SECS, allowed_skew_secs::Int=DEFAULT_SKEW_SECS, key_refresh_secs::Int=DEFAULT_KEY_REFRESH_SECS), @@ -34,7 +34,7 @@ Parameters: Keyword Parameters: - `verify`: whether to validate the server certificate -- `cacrt`: the CA certificate to use to check the server certificate +- `cacrt`: path to a CA certificate file (PEM) to use to check the server certificate. Must be a path to an existing file (since the HTTP v2 / Reseau migration, passing certificate contents or an `MbedTLS.CRT` is no longer supported) - `state_timeout_secs`: seconds for which to keep the state associated with an authorization request (default: 60 seconds), server responses beyond this are rejected as stale - `allowed_skew_secs`: while validating tokens, seconds to allow to account for time skew between machines (default: 120 seconds) - `key_refresh_secs`: time interval in which to refresh the JWT signing keys (default: 1hr) diff --git a/src/OpenIDConnect.jl b/src/OpenIDConnect.jl index b3a6390..7063727 100644 --- a/src/OpenIDConnect.jl +++ b/src/OpenIDConnect.jl @@ -2,12 +2,15 @@ module OpenIDConnect using HTTP using JSON -using MbedTLS using Base64 using Random -using SHA +using SHA using JWTs +# Import Reseau.TLS for HTTP v2 custom certificate support +import Reseau +const TLS = Reseau.TLS + const DEFAULT_SCOPES = ["openid", "profile", "email"] const DEFAULT_STATE_TIMEOUT_SECS = 60 const DEFAULT_SKEW_SECS = 2*60 @@ -37,25 +40,46 @@ struct OIDCCtx random_device::RandomDevice function OIDCCtx(issuer::String, redirect_uri::String, client_id::String, client_secret::String, scopes::Vector{String}=DEFAULT_SCOPES; - verify::Union{Nothing,Bool}=nothing, cacrt::Union{Nothing,String,MbedTLS.CRT}=nothing, + verify::Union{Nothing,Bool}=nothing, cacrt::Union{Nothing,String}=nothing, state_timeout_secs::Int=DEFAULT_STATE_TIMEOUT_SECS, allowed_skew_secs::Int=DEFAULT_SKEW_SECS, key_refresh_secs::Int=DEFAULT_KEY_REFRESH_SECS, random_device::RandomDevice=RandomDevice()) endswith(issuer, "/") || (issuer = issuer * "/") openid_config_url = issuer * ".well-known/openid-configuration" http_tls_opts = Dict{Symbol,Any}() - http_tls_opts[:socket_type_tls] = MbedTLS.SSLContext - - if verify !== nothing - http_tls_opts[:require_ssl_verification] = verify - end if cacrt !== nothing - if isa(cacrt, String) - cacrt = isfile(cacrt) ? MbedTLS.crt_parse_file(cacrt) : MbedTLS.crt_parse(cacrt) + if !isfile(cacrt) + error("cacrt must be a path to an existing certificate file; got: $cacrt") end - conf = MbedTLS.SSLConfig(verify === nothing || verify) - MbedTLS.ca_chain!(conf, cacrt) - http_tls_opts[:sslconfig] = conf + # A custom CA requires a custom HTTP.Client built around a Reseau TLS.Config. + # The verify intent is baked into the TLS.Config below (verify_peer/verify_hostname); + # we must NOT also pass require_ssl_verification, since HTTP v2 throws when that + # keyword is combined with an explicit client. + verify_peer = verify === nothing || verify + tls_config = TLS.Config( + nothing, # server_name + verify_peer, # verify_peer + verify_peer, # verify_hostname + TLS.ClientAuthMode.NoClientCert, # client_auth + nothing, # cert_file + nothing, # key_file + cacrt, # ca_file + nothing, # client_ca_file + String[], # alpn_protocols + UInt16[], # curve_preferences + Int64(30_000_000_000), # handshake_timeout_ns (30 seconds) + TLS.TLS1_2_VERSION, # min_version + nothing, # max_version + false, # session_tickets_disabled + 64, # session_cache_capacity + ) + # Create custom transport with TLS config + transport = HTTP.Transport(; tls_config) + # Create custom client + http_tls_opts[:client] = HTTP.Client(; transport) + elseif verify !== nothing + # No custom CA: control verification through the per-request keyword on the default client. + http_tls_opts[:require_ssl_verification] = verify end # fetch and store the openid config, along with the additional args for SSL diff --git a/test/runtests.jl b/test/runtests.jl index fab8aaf..bf14145 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,6 +2,7 @@ using OpenIDConnect using Test using Random using HTTP +using NetworkOptions function test_state_store() @testset "State store" begin @@ -68,7 +69,28 @@ function test_oidc_flow() end end +function test_custom_cacrt() + @testset "Custom CA certificate" begin + cafile = NetworkOptions.ca_roots_path() + + # A custom CA file builds a custom HTTP.Client; the context must construct + # and resolve the openid configuration successfully. + ctx = OIDCCtx("https://accounts.google.com", "http://127.0.0.1:8888/auth/login", "test_client_id", "test_client_secret"; cacrt=cafile) + @test OpenIDConnect.token_endpoint(ctx) == "https://oauth2.googleapis.com/token" + + # Regression: verify=false together with cacrt must not throw. HTTP v2 rejects + # require_ssl_verification overrides when an explicit client is passed, so the + # verify intent has to be carried by the TLS config instead. + ctx = OIDCCtx("https://accounts.google.com", "http://127.0.0.1:8888/auth/login", "test_client_id", "test_client_secret"; verify=false, cacrt=cafile) + @test OpenIDConnect.token_endpoint(ctx) == "https://oauth2.googleapis.com/token" + + # A cacrt that is not an existing file is rejected with a clear error. + @test_throws ErrorException OIDCCtx("https://accounts.google.com", "http://127.0.0.1:8888/auth/login", "test_client_id", "test_client_secret"; cacrt="/no/such/ca-file.pem") + end +end + @testset "OpenIDConnect" begin test_state_store() test_oidc_flow() + test_custom_cacrt() end