Skip to content

Commit

Permalink
Add SAML SP metadata endpoints and flow (#990)
Browse files Browse the repository at this point in the history
* Add samly dependency

* Set up samly

* Fix incorrect documentation

* Make code param optional in auth controller

* Create test SAML provider

* Update example config to configure SAML IdP

* Fallback to connection for code

Hacky fallback because SAML does not require a code to be sent in the
authentication flow.

* Replace FIXME comment with TODO

FIXME was causing `mix credo` to fail.

* Fix typing

* Test student saml config

* Fix rebase conflict

* Add assertion extractor

* Restructure auth provider directory

* Update SAML provider authorise

* Add SAML redirect flow

* Refactor providers with param map

* Update tests

* Update swagger

* Restructure test providers folder

* Fix dialyzer warnings

* Add SAML provider tests

* Add auth_controller tests for SAML redirect endpoint

* Ran format

* Fix sigil warning

---------

Co-authored-by: En Rong <[email protected]>
  • Loading branch information
RichDom2185 and chownces authored May 13, 2024
1 parent 423a597 commit 060355c
Show file tree
Hide file tree
Showing 33 changed files with 478 additions and 50 deletions.
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
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
@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

0 comments on commit 060355c

Please sign in to comment.