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 SAML SP metadata endpoints and flow #990

Merged
merged 24 commits into from
May 13, 2024
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
35 changes: 35 additions & 0 deletions config/cadet.exs.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ config :cadet,
# # You may need to write your own claim extractor for other providers
# claim_extractor: Cadet.Auth.Providers.CognitoClaimExtractor
# }},

# # Example SAML authentication with NUS Student IdP
# "test_saml" =>
# {Cadet.Auth.Providers.SAML,
# %{
# assertion_extractor: Cadet.Auth.Providers.NusstuAssertionExtractor,
# client_redirect_url: "http://cadet.frontend:8000/login/callback"
# }},

"test" =>
{Cadet.Auth.Providers.Config,
[
Expand Down Expand Up @@ -142,3 +151,29 @@ config :cadet,
# You may also want to change the timezone used for scheduled jobs
# config :cadet, Cadet.Jobs.Scheduler,
# timezone: "Asia/Singapore",

# # Additional configuration for SAML authentication
# # For more details, see https://github.com/handnot2/samly
# config :samly, Samly.Provider,
# idp_id_from: :path_segment,
# service_providers: [
# %{
# id: "source-academy-backend",
# certfile: "example_path/certfile.pem",
# keyfile: "example_path/keyfile.pem"
# }
# ],
# identity_providers: [
# %{
# id: "student",
# sp_id: "source-academy-backend",
# base_url: "https://example_backend/sso",
# metadata_file: "student_idp_metadata.xml"
# },
# %{
# id: "staff",
# sp_id: "source-academy-backend",
# base_url: "https://example_backend/sso",
# metadata_file: "staff_idp_metadata.xml"
# }
# ]
37 changes: 36 additions & 1 deletion config/dev.secrets.exs.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ config :cadet,
# token_url: "https://github.com/login/oauth/access_token",
# user_api: "https://api.github.com/user"
# }},

# # Example SAML authentication with NUS Student IdP
# "test_saml" =>
# {Cadet.Auth.Providers.SAML,
# %{
# assertion_extractor: Cadet.Auth.Providers.NusstuAssertionExtractor,
# client_redirect_url: "http://cadet.frontend:8000/login/callback"
# }},

"test" =>
{Cadet.Auth.Providers.Config,
[
Expand Down Expand Up @@ -90,10 +99,36 @@ config :cadet,
config :openai,
# find it at https://platform.openai.com/account/api-keys
api_key: "the actual api key",
# For source academy deployment, leave this as empty string.Ingeneral could find it at https://platform.openai.com/account/org-settings under "Organization ID".
# For source academy deployment, leave this as empty string.Ingeneral could find it at https://platform.openai.com/account/org-settings under "Organization ID".
organization_key: "",
# optional, passed to [HTTPoison.Request](https://hexdocs.pm/httpoison/HTTPoison.Request.html) options
http_options: [recv_timeout: 170_0000]

config :sentry,
dsn: "https://public_key/sentry.io/somethingsomething"

# # Additional configuration for SAML authentication
# # For more details, see https://github.com/handnot2/samly
# config :samly, Samly.Provider,
# idp_id_from: :path_segment,
# service_providers: [
# %{
# id: "source-academy-backend",
# certfile: "example_path/certfile.pem",
# keyfile: "example_path/keyfile.pem"
# }
# ],
# identity_providers: [
# %{
# id: "student",
# sp_id: "source-academy-backend",
# base_url: "https://example_backend/sso",
# metadata_file: "student_idp_metadata.xml"
# },
# %{
# id: "staff",
# sp_id: "source-academy-backend",
# base_url: "https://example_backend/sso",
# metadata_file: "staff_idp_metadata.xml"
# }
# ]
8 changes: 7 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@ config :cadet,
username: "E1234564"
# role: :student
}
]}
]},
"saml" =>
{Cadet.Auth.Providers.SAML,
%{
assertion_extractor: Cadet.Auth.Providers.NusstfAssertionExtractor,
client_redirect_url: "https://cadet.frontend/login/callback"
}}
},
autograder: [
lambda_name: "dummy"
Expand Down
3 changes: 2 additions & 1 deletion lib/cadet/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ defmodule Cadet.Application do
# Start the Quantum scheduler
worker(Cadet.Jobs.Scheduler, []),
# Start the Oban instance
{Oban, Application.fetch_env!(:cadet, Oban)}
{Oban, Application.fetch_env!(:cadet, Oban)},
{Samly.Provider, []}
]

children =
Expand Down
24 changes: 16 additions & 8 deletions lib/cadet/auth/provider.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,40 @@ defmodule Cadet.Auth.Provider do
it for a token with the OAuth2 provider, and then retrieves the user ID and name.
"""

@type code :: String.t()
@type token :: String.t()
@type client_id :: String.t()
@type redirect_uri :: String.t()
@type error :: :upstream | :invalid_credentials | :other
@type provider_instance :: String.t()
@type username :: String.t()
@type prefix :: String.t()
@type authorise_params :: %{
conn: Plug.Conn.t(),
provider_instance: provider_instance,
code: String.t() | nil,
client_id: String.t() | nil,
redirect_uri: String.t() | nil
}

@doc "Exchanges the OAuth2 authorisation code for a token and the user ID."
@callback authorise(any(), code, client_id, redirect_uri) ::
@callback authorise(any(), authorise_params()) ::
{:ok, %{token: token, username: String.t()}} | {:error, error(), String.t()}

@doc "Retrieves the name of the user with the associated token."
@callback get_name(any(), token) :: {:ok, String.t()} | {:error, error(), String.t()}

@spec get_instance_config(provider_instance) :: {module(), any()} | nil
@spec get_instance_config(provider_instance()) :: {module(), any()} | nil
def get_instance_config(instance) do
Application.get_env(:cadet, :identity_providers, %{})[instance]
end

@spec authorise(provider_instance, code, client_id, redirect_uri) ::
@spec authorise(authorise_params) ::
{:ok, %{token: token, username: String.t()}} | {:error, error(), String.t()}
def authorise(instance, code, client_id, redirect_uri) do
def authorise(
params = %{
provider_instance: instance
}
) do
case get_instance_config(instance) do
{provider, config} -> provider.authorise(config, code, client_id, redirect_uri)
{provider, config} -> provider.authorise(config, params)
_ -> {:error, :other, "Invalid or nonexistent provider config"}
end
end
Expand Down
8 changes: 6 additions & 2 deletions lib/cadet/auth/providers/adfs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ defmodule Cadet.Auth.Providers.ADFS do

@type config :: %{token_endpoint: String.t(), modules: %{}}

@spec authorise(config(), Provider.code(), Provider.client_id(), Provider.redirect_uri()) ::
@spec authorise(config(), Provider.authorise_params()) ::
{:ok, %{token: Provider.token(), username: String.t()}}
| {:error, Provider.error(), String.t()}
def authorise(config, code, client_id, redirect_uri) do
def authorise(config, %{
code: code,
client_id: client_id,
redirect_uri: redirect_uri
}) do
query =
URI.encode_query(%{
client_id: client_id,
Expand Down
6 changes: 4 additions & 2 deletions lib/cadet/auth/providers/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ defmodule Cadet.Auth.Providers.Config do

@behaviour Provider

@spec authorise(any(), Provider.code(), Provider.client_id(), Provider.redirect_uri()) ::
@spec authorise(any(), Provider.authorise_params()) ::
{:ok, %{token: Provider.token(), username: String.t()}}
| {:error, Provider.error(), String.t()}
def authorise(config, code, _client_id, _redirect_uri) do
def authorise(config, %{
code: code
}) do
case Enum.find(config, nil, fn %{code: this_code} -> code == this_code end) do
%{token: token, username: username} ->
{:ok, %{token: token, username: username}}
Expand Down
8 changes: 6 additions & 2 deletions lib/cadet/auth/providers/github.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ defmodule Cadet.Auth.Providers.GitHub do
user_api: String.t()
}

@spec authorise(config(), Provider.code(), Provider.client_id(), Provider.redirect_uri()) ::
@spec authorise(config(), Provider.authorise_params()) ::
{:ok, %{token: Provider.token(), username: String.t()}}
| {:error, Provider.error(), String.t()}
def authorise(config, code, client_id, redirect_uri) do
def authorise(config, %{
code: code,
client_id: client_id,
redirect_uri: redirect_uri
}) do
token_headers = [
{"Content-Type", "application/x-www-form-urlencoded"},
{"Accept", "application/json"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ defmodule Cadet.Auth.Providers.OpenID do

@type config :: %{openid_provider: atom(), claim_extractor: module()}

@spec authorise(config, Provider.code(), Provider.client_id(), Provider.redirect_uri()) ::
@spec authorise(config(), Provider.authorise_params()) ::
{:ok, %{token: Provider.token(), username: String.t()}}
| {:error, Provider.error(), String.t()}
def authorise(config, code, _client_id, redirect_uri) do
def authorise(config, %{
code: code,
redirect_uri: redirect_uri
}) do
%{openid_provider: openid_provider, claim_extractor: claim_extractor} = config

with {:token, {:ok, token_map}} <-
Expand Down
15 changes: 15 additions & 0 deletions lib/cadet/auth/providers/saml/nusstf_assertion_extractor.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule Cadet.Auth.Providers.NusstfAssertionExtractor do
@moduledoc """
Extracts fields from NUS Staff IdP SAML assertions.
"""

@behaviour Cadet.Auth.Providers.AssertionExtractor

def get_username(assertion) do
Map.get(assertion.attributes, "SamAccountName")
end

def get_name(assertion) do
Map.get(assertion.attributes, "DisplayName")
end
end
15 changes: 15 additions & 0 deletions lib/cadet/auth/providers/saml/nusstu_assertion_extractor.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule Cadet.Auth.Providers.NusstuAssertionExtractor do
@moduledoc """
Extracts fields from NUS Student IdP SAML assertions.
"""

@behaviour Cadet.Auth.Providers.AssertionExtractor

def get_username(assertion) do
Map.get(assertion.attributes, "samaccountname")
end

def get_name(assertion) do
RichDom2185 marked this conversation as resolved.
Show resolved Hide resolved
Map.get(assertion.attributes, "samaccountname")
end
end
49 changes: 49 additions & 0 deletions lib/cadet/auth/providers/saml/saml.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
defmodule Cadet.Auth.Providers.SAML do
chownces marked this conversation as resolved.
Show resolved Hide resolved
@moduledoc """
Provides identity using SAML.
"""
alias Cadet.Auth.Provider

@behaviour Provider

@type config :: %{assertion_extractor: module()}

@spec authorise(config(), Provider.authorise_params()) ::
{:ok, %{token: Provider.token(), username: String.t()}}
| {:error, Provider.error(), String.t()}
def authorise(config, %{
conn: conn
}) do
%{assertion_extractor: assertion_extractor} = config

with {:assertion, assertion} when not is_nil(assertion) <-
{:assertion, Samly.get_active_assertion(conn)},
{:name, name} when not is_nil(name) <- {:name, assertion_extractor.get_name(assertion)},
{:username, username} when not is_nil(username) <-
{:username, assertion_extractor.get_username(assertion)} do
{:ok,
%{
token: Jason.encode!(%{name: name}),
username: username
}}
else
{:assertion, nil} -> {:error, :invalid_credentials, "Missing SAML assertion!"}
{:name, nil} -> {:error, :invalid_credentials, "Missing name attribute!"}
{:username, nil} -> {:error, :invalid_credentials, "Missing username attribute!"}
end
end

@spec get_name(any(), Provider.token()) ::
{:ok, String.t()} | {:error, Provider.error(), String.t()}
def get_name(_config, token) do
{:ok, Jason.decode!(token)["name"]}
end
end

defmodule Cadet.Auth.Providers.AssertionExtractor do
@moduledoc """
A behaviour for modules that extract fields from SAML assertions.
"""
@callback get_username(Samly.Assertion) :: String.t() | nil
@callback get_name(Samly.Assertion) :: String.t() | nil
end
Loading
Loading