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

OpenID Connect support #637

Open
wants to merge 39 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
4df2c3e
First pass at a basic OIDC flow
netpro2k Oct 28, 2020
4251e2f
Cleanup id token handling
netpro2k Oct 29, 2020
6b10355
Handle using multiple public keys for OIDC
netpro2k Nov 13, 2020
b4a5951
Don't assume name of authorize/token endponts
netpro2k Nov 13, 2020
9239a47
Better displayname handling
netpro2k Nov 13, 2020
59e3edc
Add habitat config for OIDC
netpro2k Nov 18, 2020
5f507f5
Fix escaping and formatting in habitat config
netpro2k Nov 21, 2020
888555b
Delint
netpro2k Dec 2, 2020
1a6339c
Cleanup dead code and remove debugging
netpro2k Dec 2, 2020
a645f24
delint
netpro2k Dec 2, 2020
60aa6d2
No need to make scopes configurable right now
netpro2k Dec 2, 2020
56c7f8e
Merge branch 'master' into avn-oidc-login
rawnsley Oct 12, 2022
a17b7b1
OIDC example credentials for local testing
rawnsley Oct 14, 2022
d1dc0e5
Guardian needs explicit list of signing algorithms to check
rawnsley Oct 14, 2022
95105b0
Function requires options to be supplied in keyword format
rawnsley Oct 14, 2022
b8d7292
Spelling typo
rawnsley Oct 14, 2022
44eabd2
Log OIDC errors on the server side for investigation
rawnsley Oct 14, 2022
cf4510d
Missing require import
rawnsley Oct 14, 2022
d96a6e0
Passthrough UserInfo from OIDC
rawnsley Oct 17, 2022
8849a40
Encapsulated OIDC setup into single RemoteOIDCClient module and simpl…
rawnsley Oct 19, 2022
d5d996e
Definitive list of allowed algorithms for JSON signing
rawnsley Oct 19, 2022
f9f2cb6
Use supplied scopes for fetching token
rawnsley Oct 19, 2022
ef9f963
Small change to test data
rawnsley Oct 19, 2022
1f406f3
Statistic policy outside the scope of this PR
rawnsley Oct 19, 2022
5096e48
Make some functions private
rawnsley Oct 19, 2022
664d933
Comment update
rawnsley Oct 19, 2022
d630e94
Hashed identifier that is unique for each OIDC provider and won't cla…
rawnsley Oct 20, 2022
1adba56
Email account creation disabled in OIDC mode. The failure mode is as …
rawnsley Oct 20, 2022
996adea
Don't allow OIDC auth unless explicitly enabled
rawnsley Oct 20, 2022
2f7bfe0
Lint suggestions
rawnsley Oct 21, 2022
599cd70
Make list of permitted claims more a configurable parameter
rawnsley Oct 24, 2022
4009651
Fix filtering when UserInfo endpoint is active
rawnsley Oct 24, 2022
f8e051a
More focused test parameters
rawnsley Oct 24, 2022
ee7ccc2
Support for additional authentication parameters
rawnsley Oct 24, 2022
8b37956
Missing from last checkin
rawnsley Oct 24, 2022
254c2b0
Merge branch 'master' into avn-oidc-login
rawnsley Oct 24, 2022
50b0c06
Merge branch 'master' into avn-oidc-login
rawnsley Oct 28, 2022
ccfe143
Merge branch 'master' into avn-oidc-login
rawnsley Nov 8, 2022
66cbdf4
Merge branch 'master' into avn-oidc-login
rawnsley Nov 21, 2022
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
9 changes: 9 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,12 @@ config :ret, Ret.Locking,
config :ret, Ret.Repo.Migrations.AdminSchemaInit, postgrest_password: "password"
config :ret, Ret.StatsJob, node_stats_enabled: false, node_gauges_enabled: false
config :ret, Ret.Coturn, realm: "ret"

# OIDC test server https://oidctest.wsweet.org/
config :ret, Ret.RemoteOIDCClient,
openid_configuration: "https://oidctest.wsweet.org/.well-known/openid-configuration",
scopes: "openid profile email roles",
permitted_claims: ["sub", "email", "name", "preferred_username", "roles"],
client_id: "private",
client_secret: "tardis",
additional_authorization_parameters: "&prompt=select_account"
8 changes: 8 additions & 0 deletions config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,11 @@ config :ret, Ret.StatsJob, node_stats_enabled: false, node_gauges_enabled: false

# Default repo check and page check to off so for polycosm hosts database + s3 hits can go idle
config :ret, RetWeb.HealthController, check_repo: false

config :ret, Ret.RemoteOIDCClient,
# Conventional default scopes
scopes: "openid profile email",
# Standard claims https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
permitted_claims: ["sub", "name", "given_name", "family_name", "middle_name", "nickname",
"preferred_username", "profile", "picture", "website", "email", "email_verified", "gender",
"birthdate", "zoneinfo", "locale", "phone_number", "phone_number_verified", "address", "updated_at"]
13 changes: 13 additions & 0 deletions habitat/config/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,19 @@ realm = "{{ cfg.turn.realm }}"
public_tls_ports = "{{ cfg.turn.public_tls_ports }}"
{{/if}}

{{#if cfg.oidc }}
{{#with cfg.oidc }}
[ret."Elixir.Ret.RemoteOIDCClient"]
openid_configuration = {{ toToml openid_configuration }}
scopes = {{ toToml scopes }}
permitted_claims = {{ toToml permitted_claims }}
client_id = {{ toToml client_id }}
client_secret = {{ toToml client_secret }}
additional_authorization_parameters = {{ toToml additional_authorization_parameters }}

{{/with}}
{{/if}}

[web_push_encryption.vapid_details]
subject = "{{ cfg.web_push.subject }}"
public_key = "{{ cfg.web_push.public_key }}"
Expand Down
14 changes: 14 additions & 0 deletions lib/ret/oauth_token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ defmodule Ret.OAuthToken do

token
end

def token_for_oidc_request(topic_key, session_id) do
{:ok, token, _claims} =
Ret.OAuthToken.encode_and_sign(
# OAuthTokens do not have a resource associated with them
nil,
%{topic_key: topic_key, session_id: session_id, aud: :ret_oidc},
allowed_algos: ["HS512"],
ttl: {10, :minutes},
allowed_drift: 60 * 1000
)

token
end
end

defmodule Ret.OAuthTokenSecretFetcher do
Expand Down
91 changes: 91 additions & 0 deletions lib/ret/remote_oidc_client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
defmodule Ret.RemoteOIDCClient do
@moduledoc """
This represents an OpenID client configured via the openid_configuration parameter,
which should point to a discovery endpoint https://openid.net/specs/openid-connect-discovery-1_0.html
Downloaded configuration files openid-configuration and jwks_uri are cached indefinately.
"""

require Logger

def get_openid_configuration_uri() do
Application.get_env(:ret, __MODULE__)[:openid_configuration]
end

defp download_openid_configuration() do
Logger.info("Downloading OIDC configuration from #{get_openid_configuration_uri()}")
result = get_openid_configuration_uri()
|> Ret.HttpUtils.retry_get_until_success
|> Map.get(:body)
|> Poison.decode!()
:persistent_term.put(:openid_configuration_cache, result)
Logger.info("Downloaded OIDC configuration: #{inspect(result)}")
result
end

defp get_openid_configuration() do
:persistent_term.get(:openid_configuration_cache, nil) || download_openid_configuration()
end

defp get_jwks_uri() do
get_openid_configuration() |> Map.get("jwks_uri")
end

defp download_jwks() do
Logger.info("Downloading JWKS from #{get_jwks_uri()}")
result = get_jwks_uri()
|> Ret.HttpUtils.retry_get_until_success
|> Map.get(:body)
|> Poison.decode!()
result |> IO.inspect
:persistent_term.put(:openid_jwks_cache, result)
Logger.info("Downloaded JWKS: #{inspect(result)}")
result
end

def get_jwks() do
:persistent_term.get(:openid_jwks_cache, nil) || download_jwks()
end

def get_auth_endpoint() do
get_openid_configuration() |> Map.get("authorization_endpoint")
end

def get_token_endpoint() do
get_openid_configuration() |> Map.get("token_endpoint")
end

def get_allowed_algos() do
get_openid_configuration() |> Map.get("id_token_signing_alg_values_supported")
end

def get_userinfo_endpoint() do
# Optional in spec
get_openid_configuration() |> Map.get("userinfo_endpoint")
end

def get_scopes_supported() do
# Optional in spec
get_openid_configuration() |> Map.get("scopes_supported")
end

def get_scopes() do
Application.get_env(:ret, __MODULE__)[:scopes]
end

def get_permitted_claims() do
Application.get_env(:ret, __MODULE__)[:permitted_claims]
end

def get_client_id() do
Application.get_env(:ret, __MODULE__)[:client_id]
end

def get_client_secret() do
Application.get_env(:ret, __MODULE__)[:client_secret]
end

def get_additional_authorization_parameters() do
Application.get_env(:ret, __MODULE__)[:additional_authorization_parameters]
end

end
38 changes: 38 additions & 0 deletions lib/ret/remote_oidc_token.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
defmodule Ret.RemoteOIDCToken do
@moduledoc """
This represents an OpenID Connect token returned from a remote service.
These tokens are never created locally, only ever provided externally and verified locally.
"""
use Guardian,
otp_app: :ret,
secret_fetcher: Ret.RemoteOIDCTokenSecretsFetcher

def subject_for_token(_, _), do: {:ok, nil}
def resource_from_claims(_), do: {:ok, nil}
end

defmodule Ret.RemoteOIDCTokenSecretsFetcher do
@moduledoc """
This represents the public keys for an OpenID Connect endpoint used to verify tokens.
The public keys will be configured by an admin for a particular setup. These can not be used for signing.
"""

def fetch_signing_secret(_mod, _opts) do
{:error, :not_implemented}
end

def fetch_verifying_secret(_mod, %{"kid" => kid, "typ" => "JWT"}, _opts) do
# TODO force cache to refresh when unknown kid found to support key rotation
# as per https://openid.net/specs/openid-connect-core-1_0.html#RotateSigKeys
case Ret.RemoteOIDCClient.get_jwks()
|> Map.get("keys")
|> Enum.find(&(Map.get(&1, "kid") == kid)) do
nil -> {:error, :invalid_key_id}
key -> {:ok, key |> JOSE.JWK.from_map()}
end
end

def fetch_verifying_secret(_mod, _token_headers_, _optss) do
{:error, :invalid_token}
end
end
10 changes: 7 additions & 3 deletions lib/ret_web/channels/auth_channel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule RetWeb.AuthChannel do
use RetWeb, :channel
import Canada, only: [can?: 2]

alias Ret.{Statix, LoginToken, Account, Crypto}
alias Ret.{Statix, LoginToken, Account, Crypto, AppConfig}

intercept(["auth_credentials"])

Expand All @@ -25,8 +25,10 @@ defmodule RetWeb.AuthChannel do

account = email |> Account.account_for_email()
account_disabled = account && account.state == :disabled
# Accounts can only be created if the general setting is enabled and the server is not in OIDC mode
can_create_email_accounts = can?(nil, create_account(nil)) && !AppConfig.get_config_bool("auth|use_oidc")

if !account_disabled && (can?(nil, create_account(nil)) || !!account) do
if !account_disabled && (can_create_email_accounts || !!account) do
# Create token + send email
%LoginToken{token: token, payload_key: payload_key} = LoginToken.new_login_token_for_email(email)

Expand Down Expand Up @@ -98,7 +100,9 @@ defmodule RetWeb.AuthChannel do
defp broadcast_credentials_and_payload(nil, _payload, _socket), do: nil

defp broadcast_credentials_and_payload(identifier_hash, payload, socket) do
account = identifier_hash |> Account.account_for_login_identifier_hash(can?(nil, create_account(nil)))
# Accounts can only be created if the general setting is enabled and the server is not in OIDC mode
can_create_email_accounts = can?(nil, create_account(nil)) && !AppConfig.get_config_bool("auth|use_oidc")
account = identifier_hash |> Account.account_for_login_identifier_hash(can_create_email_accounts)
credentials = account |> Account.credentials_for_account()
broadcast!(socket, "auth_credentials", %{credentials: credentials, payload: payload})
end
Expand Down
Loading