Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OpenIDConnect #5

Closed
danlooo opened this issue Jul 11, 2023 · 12 comments
Closed

Add OpenIDConnect #5

danlooo opened this issue Jul 11, 2023 · 12 comments

Comments

@danlooo
Copy link
Collaborator

danlooo commented Jul 11, 2023

Many openEO backends, e.g. https://openeocloud.vito.be/openeo/1.0.0/ require OpenIDConnect (OICD) for authentification.
There is OpenIDConnect.jl, but the example only shows a OIDC client in JavaScript. I have not seen a real world implementation of OIDC in julia yet. We also need to register OpenEOClient.jl e.g. at egi.eu to get client_id and client_secret.

@danlooo
Copy link
Collaborator Author

danlooo commented Jul 11, 2023

An example https://github.com/tanmaykm/OpenIDConnect.jl/blob/master/tools/oidc_standalone.jl

Workflow:

  1. Create OpenIDConnect.jl context using:
  • issuer auth url
  • authority .well-known/openid-configuration url
  • client id
  • client secret
  1. Start a Mux.jl web server with a OIDC javascript client webpage
  2. Let user to login by going to http://127.0.0.1:8888 in the browser
  3. Mux.jl web server waits for the auth server callback at local redirect_url
  4. Get token from function OpenIDConnect.show_token using the response from the Mux webserver

@m-mohr
Copy link
Member

m-mohr commented Jul 12, 2023

Sounds reasonable, but only works if the client is run locally. A Julia client that runs on a remote machine for e.g. a notebook will not work this way. Then you need the Device Code Flow (maybe it can be inspired from the openEO Python client implementation). @soxofaan

For client ID + secret for openEO Platform, I think you can contact @bschumac .

@soxofaan
Copy link
Member

Indeed, I would not bother implementing the OIDC authorization code flow, that's a lot of complexity that is not going to work in various use cases anyway. Note: it was the initial OIDC flow implemented in the openeo Python client, but I don't think anybody uses that anymore now that we have better alternatives. We're probably just going to drop the option completely: Open-EO/openeo-python-client#235

instead try to implement these OIDC flows instead:

  • "device code flow" for initial interactive auth
  • refresh token flow for automatic non-interactive auth (as long as refresh token is valid)

Both of these are relatively easy in terms of HTTP requests and you don't necessarily need a third party OIDC oriented library for that (e.g. we just use a generic HTTP lib in the openeo python client)

@danlooo
Copy link
Collaborator Author

danlooo commented Jul 13, 2023

Thanks for the input! I created a toy example device code flow for a test app of mine. Seems indeed pretty simple:

using JSON
using HTTP

function auth_oidc_device_flow(auth_host::String, client_id::String)
    url = "$(auth_host)/oauth/device/code"
    headers = ["content-type" => "application/x-www-form-urlencoded"]
    args = Dict(
        "client_id" => client_id,
        "scope" => "profile email",
        "audience" => "test-api"
    )
    body = HTTP.URIs.escapeuri(args)
    device_code_request = HTTP.post(url, headers, body) |> x -> x.body |> String |> JSON.parse
    @info "Please log in using any device at:\n" *
          device_code_request["verification_uri_complete"] *
          "\nWaiting until log in succeeded..."

    while true
        url = "$(auth_host)/oauth/token"
        headers = ["content-type" => "application/x-www-form-urlencoded"]
        args = Dict(
            "grant_type" => "urn:ietf:params:oauth:grant-type:device_code",
            "device_code" => device_code_request["device_code"],
            "client_id" => client_id
        )
        body = HTTP.URIs.escapeuri(args)

        try
            access_token = HTTP.post(url, headers, body) |> x -> x.body |> String |> JSON.parse
            return access_token
        catch e
            sleep(device_code_request["interval"])
        end
    end
end

client_id = "XXXclient_idXXXhuikfdzui"
auth_host = "https://dev-appIDXXXX.us.auth0.com"
access_token = auth_oidc_device_flow(auth_host, client_id)

@soxofaan
Copy link
Member

Indeed, that looks like the essential part of it.

Note that access_token = HTTP.post(url, headers, body) |> x -> x.body |> String |> JSON.parse will be an object not only containing the access token, but also a refresh token, if set up correctly (e.g. if the OIDC client is configured for that, and something like the "offline_access" scope is added to the initial request).
You can use this refresh token then with the refresh token auth flow (if you persist this refresh token for long term usage, make sure to do that in some reasonable secure way, e.g. a private local file or some keyring/keychain system)

@danlooo
Copy link
Collaborator Author

danlooo commented Aug 3, 2023

EDIT: Works now. Use ' ' instead of ',' to separate scopes in the request

After reading RFC7636, I managed to log in into openEO vito cloud using OIDC device flow + PKCE. However, I've got error 403 forbidden after using the access token. Proper enrollment in openEO Platform virtual organization is required. Is this not by default? I'm using a 30 day trail account (yet).

# @see https://developers.onelogin.com/openid-connect/guides/auth-flow-pkce

using SHA
using Random
using Base64
using HTTP
using JSON3

function auth_oidc_device_flow_pkce(discovery_url::String, client_id::String, scopes::AbstractVector)
    oidc_config = HTTP.get(discovery_url).body |> JSON3.read

    code_verifier = randstring(128)
    hash = sha256(code_verifier)
    # see https://datatracker.ietf.org/doc/html/rfc7636#appendix-B
    code_challenge = hash |> base64encode |> x -> strip(x, '=') |> x -> replace(x, "+" => "-", "/" => "_")

    headers = ["content-type" => "application/x-www-form-urlencoded"]
    args = Dict(
        "client_id" => client_id,
        "grant_type" => "urn:ietf:params:oauth:grant-type:device_code",
        "code_challenge_method" => "S256",
        "code_challenge" => code_challenge, 
        "scope" => join(scopes, " ") # EDIT: Use space instead of ','
    )
    body = HTTP.URIs.escapeuri(args)
    device_code_request = HTTP.post(oidc_config.device_authorization_endpoint, headers, body) |> x -> x.body |> String |> JSON3.read

    @info "Please log in using any device at:\n" *
          device_code_request.verification_uri_complete *
          "\nWaiting until log in succeeded..."

    while true
        headers = ["content-type" => "application/x-www-form-urlencoded"]
        args = Dict(
            "grant_type" => "urn:ietf:params:oauth:grant-type:device_code",
            "device_code" => device_code_request.device_code,
            "client_id" => client_id,
            "code_verifier" => code_verifier
        )
        body = HTTP.URIs.escapeuri(args)

        try
            access_token = HTTP.post(oidc_config.token_endpoint, headers, body) |>
                           x -> x.body |> String |> JSON3.read |> x -> x.access_token
            return access_token
        catch e
            sleep(device_code_request.interval)
        end
    end
end

openeo_oidc_provider =
    HTTP.get("https://openeocloud.vito.be/openeo/1.0.0/credentials/oidc").body |>
    JSON3.read |>
    x -> x.providers[1]

openeo_oidc_discovery_url = "$(openeo_oidc_provider.issuer).well-known/openid-configuration"
# https://aai.egi.eu/auth/realms/egi/.well-known/openid-configuration
openeo_client_id = openeo_oidc_provider.default_clients[1].id
# openeo-platform-default-client

access_token = auth_oidc_device_flow_pkce(openeo_oidc_discovery_url, openeo_client_id, openeo_oidc_provider.scopes)
# string of length 1081

# test
headers = [
    "Authorization" => "Bearer oidc/$(openeo_oidc_provider.id)/$access_token"
]

response = HTTP.get("https://openeocloud.vito.be/openeo/1.0.0/jobs", headers)
# HTTP/1.1 403 Forbidden
# {"code":"PermissionsInsufficient","id":"r-88f2795606f84712a71b609aaaaa","message":"Proper enrollment in openEO Platform virtual organization is required."}

@m-mohr
Copy link
Member

m-mohr commented Aug 3, 2023

Which scopes did you send to retrieve the token?
You need to list all 4 from here: https://openeocloud.vito.be/openeo/1.0.0/credentials/oidc
Otherwise the enrollment is missing in the token...

Does logging in with the Web Editor work? If yes, it's your code, if no, it's your account (and Benjamin needs to help) ;-)

@soxofaan
Copy link
Member

soxofaan commented Aug 3, 2023

Try to get it working first with a backend like https://openeo.vito.be/openeo/1.1/ instead of openeocloud.

openeocloud has some additional requirements for the EGI account, which are not part of core openEO API

@m-mohr
Copy link
Member

m-mohr commented Aug 3, 2023

@soxofaan

openeocloud has some additional requirements for the EGI account, which are not part of core openEO API

Huh? What is it?

@soxofaan
Copy link
Member

soxofaan commented Aug 3, 2023

These "Proper enrollment in openEO Platform virtual organization is required" checks

@m-mohr
Copy link
Member

m-mohr commented Aug 3, 2023

But that should be captured by sending the two additional scopes that are exposed in the endpoint, no?

@danlooo
Copy link
Collaborator Author

danlooo commented Aug 3, 2023

Now it seems to work even with https://openeocloud.vito.be. I took all the 4 scopes openid, email, eduperson_entitlement and eduperson_scoped_affiliation. The error was in concatenating the scopes with a , instead of a space. Now, the webpage https://aai.egi.eu/device?user_code=QFNZ-XXXX asks for "Grant Access to openEO Platform" explicitly. So the user needs to click ok, not any admin.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants