Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ jobs:
arch: x86
version: 1
steps:
- uses: actions/checkout@v2
- uses: julia-actions/setup-julia@v1
- uses: actions/checkout@v4
- uses: julia-actions/setup-julia@v2
with:
version: ${{ matrix.version }}
arch: ${{ matrix.arch }}
- uses: actions/cache@v1
- uses: actions/cache@v4
env:
cache-name: cache-artifacts
with:
Expand All @@ -50,6 +50,6 @@ jobs:
- uses: julia-actions/julia-buildpkg@v1
- uses: julia-actions/julia-runtest@v1
- uses: julia-actions/julia-processcoverage@v1
- uses: codecov/codecov-action@v1
- uses: codecov/codecov-action@v5
with:
file: lcov.info
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
*.jl.cov
*.jl.*.cov
*.jl.mem
Manifest.toml
/docs/build/
/docs/Manifest.toml
4 changes: 3 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
JWTs = "d850fbd6-035d-5a70-a269-1ca2e636ac6c"
MbedTLS = "739be429-bea8-5141-9913-cc70e7f3736d"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce"

[compat]
julia = "1"
HTTP = "0.8, 0.9, 1"
JSON = "0.21"
JWTs = "0.1,0.2"
JWTs = "0.1, 0.2, 0.3"
MbedTLS = "0.6.8, 0.7, 1"
SHA = "<0.0.1, 0.7, 1"

[extras]
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Expand Down
52 changes: 47 additions & 5 deletions src/OpenIDConnect.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ using JSON
using MbedTLS
using Base64
using Random
using SHA
using JWTs

const DEFAULT_SCOPES = ["openid", "profile", "email"]
Expand All @@ -21,6 +22,7 @@ The context holds request states, and configuration options.
"""
struct OIDCCtx
states::Dict{String,Float64}
code_verifiers::Dict{String,String}
state_timeout_secs::Int
allowed_skew_secs::Int
openid_config::Dict{String,Any}
Expand Down Expand Up @@ -60,7 +62,7 @@ struct OIDCCtx
openid_config = JSON.parse(String(HTTP.request("GET", openid_config_url; status_exception=true, http_tls_opts...).body))
validator = JWKSet(openid_config["jwks_uri"])

new(Dict{String,Float64}(), state_timeout_secs, allowed_skew_secs, openid_config, http_tls_opts, validator, key_refresh_secs, 0.0, client_id, client_secret, scopes, redirect_uri, random_device)
new(Dict{String,Float64}(), Dict{String,String}(), state_timeout_secs, allowed_skew_secs, openid_config, http_tls_opts, validator, key_refresh_secs, 0.0, client_id, client_secret, scopes, redirect_uri, random_device)
end
end

Expand All @@ -72,6 +74,11 @@ function remember_state(ctx::OIDCCtx, state::String)
nothing
end

function remember_verifier(ctx::OIDCCtx, state::String, code_verifier::String)
ctx.code_verifiers[state] = code_verifier
nothing
end

function validate_state(ctx::OIDCCtx, state::String)
statestore = ctx.states
if state in keys(statestore)
Expand All @@ -88,13 +95,29 @@ function validate_state(ctx::OIDCCtx, state::String)
false
end

function purge_states(ctx::OIDCCtx)
function purge_states!(ctx::OIDCCtx)
tnow = time()
tmout = ctx.state_timeout_secs
filter!(nv->(tnow-nv[2])>tmout, ctx.states)
nv_keys = findall(nv->(tnow-nv)>tmout, ctx.states)
for k in nv_keys
delete!(ctx.states, k)
delete!(ctx.code_verifiers, k)
end
nothing
end

# for compatibility
const purge_states = purge_states!

function generate_code_challenge(ctx::Union{OIDCCtx,Nothing})
code_verifier = ctx === nothing ? randstring(128) : randstring(ctx.random_device, 128)
hash = sha256(code_verifier)
# see https://datatracker.ietf.org/doc/html/rfc7636#appendix-B
code_challenge = replace(rstrip(base64encode(hash), '='), '+' => '-', '/' => '_')

return code_challenge, code_verifier
end

"""
API calling error detected by this library
"""
Expand All @@ -119,13 +142,21 @@ Acceptable optional args as listed in section 3.1.2.1 of specifications (https:/
Returns a String with the redirect URL.
Caller must perform the redirection.
"""
function flow_request_authorization_code(ctx::OIDCCtx; nonce=nothing, display=nothing, prompt=nothing, max_age=nothing, ui_locales=nothing, id_token_hint=nothing, login_hint=nothing, acr_values=nothing)
function flow_request_authorization_code(ctx::OIDCCtx; nonce=nothing, display=nothing, prompt=nothing, max_age=nothing, ui_locales=nothing, id_token_hint=nothing, login_hint=nothing, acr_values=nothing, pkce::Bool=false)
@debug("oidc negotiation: initiating...")
scopes = join(ctx.scopes, ' ')
state = randstring(ctx.random_device, 10)
remember_state(ctx, state)

query = Dict("response_type"=>"code", "client_id"=>ctx.client_id, "redirect_uri"=>ctx.redirect_uri, "scope"=>scopes, "state"=>state)

if pkce
code_challenge, code_verifier = generate_code_challenge(ctx)
remember_verifier(ctx, state, code_verifier)
query["code_challenge_method"] = "S256"
query["code_challenge"] = code_challenge
end

(nonce === nothing) || (query["nonce"] = String(nonce))
(display === nothing) || (query["display"] = String(display))
(prompt === nothing) || (query["prompt"] = String(prompt))
Expand Down Expand Up @@ -157,6 +188,11 @@ function flow_get_authorization_code(ctx::OIDCCtx, @nospecialize(query))

code = get(query, "code", get(query, :code, nothing))
if code !== nothing
if haskey(ctx.code_verifiers, state)
code_verifier = pop!(ctx.code_verifiers, state)
# as space characters are not allowed in code strings, it is safe to conacatenate with ' '
code *= " $code_verifier"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should have a new Code struct that can hold both code and code_verifier? There is a small chance that it may break code that assumes the returned code to be a string. But it seems much cleaner.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same thoughts, but I didn't want it to be breaking. Then I verified that a space character is not part of the code (https://docs.unidy.io/technical-documentation/43Yox8946664zbsR6p9VyD/oidc-authorization-flow-requesting-an-authorization-code/4c2Hsrxi5YB8w2HScPWnjx), so I opted for the current solution.
If you are in favour of a type, say

struct Code
    code::String
    verifier::String
end

we could add

Base.convert(String, code::Code) = code.code

or

Base.convert(String, code::Code) = isempty(code.verifier) ? code.code : "$(code.code) $(code.verifier)"

which would avoid a part of breaking scenarios.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, thanks for explaining. Let's keep it as it is.

end
return String(code)
end

Expand Down Expand Up @@ -194,11 +230,17 @@ Returns a JSON object containing tokens on success.
Returns a AuthServerError or APIError object on failure.
"""
function flow_get_token(ctx::OIDCCtx, code)
sc = split(code, ' ')
pkce = length(sc) > 1
code, code_verifier = pkce ? String.(sc[1:2]) : [String(code), ""]

data = Dict("grant_type"=>"authorization_code",
"code"=>String(code),
"code"=>code,
"redirect_uri"=>ctx.redirect_uri,
"client_id"=>ctx.client_id,
"client_secret"=>ctx.client_secret)
pkce && push!(data, "code_verifier" => code_verifier)

headers = Dict("Content-Type"=>"application/x-www-form-urlencoded")
tok_res = HTTP.request("POST", token_endpoint(ctx), headers, HTTP.URIs.escapeuri(data); status_exception=false, ctx.http_tls_opts...)
return parse_token_response(tok_res)
Expand Down
Loading