feat(GitHub)!: Add GitHub authentication strategy. (#125)

This commit is contained in:
James Harton 2023-01-12 17:23:40 +13:00 committed by GitHub
parent 3885ab609f
commit 4129aa969a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 97 additions and 101 deletions

View file

@ -5,7 +5,7 @@ spark_locals_without_parens = [
auth0: 2, auth0: 2,
auth_method: 1, auth_method: 1,
authorization_params: 1, authorization_params: 1,
authorize_path: 1, authorize_url: 1,
client_id: 1, client_id: 1,
client_secret: 1, client_secret: 1,
confirm_action_name: 1, confirm_action_name: 1,
@ -60,9 +60,9 @@ spark_locals_without_parens = [
store_token_action_name: 1, store_token_action_name: 1,
subject_name: 1, subject_name: 1,
token_lifetime: 1, token_lifetime: 1,
token_path: 1, token_url: 1,
token_resource: 1, token_resource: 1,
user_path: 1 user_url: 1
] ]
[ [

View file

@ -34,15 +34,20 @@ config :ash_authentication,
redirect_uri: "http://localhost:4000/auth", redirect_uri: "http://localhost:4000/auth",
client_secret: System.get_env("OAUTH2_CLIENT_SECRET"), client_secret: System.get_env("OAUTH2_CLIENT_SECRET"),
site: System.get_env("OAUTH2_SITE"), site: System.get_env("OAUTH2_SITE"),
authorize_path: "/authorize", authorize_url: "#{System.get_env("OAUTH2_SITE")}/authorize",
token_path: "/oauth/token", token_url: "#{System.get_env("OAUTH2_SITE")}/oauth/token",
user_path: "/userinfo" user_url: "#{System.get_env("OAUTH2_SITE")}/userinfo"
], ],
auth0: [ auth0: [
client_id: System.get_env("OAUTH2_CLIENT_ID"), client_id: System.get_env("OAUTH2_CLIENT_ID"),
redirect_uri: "http://localhost:4000/auth", redirect_uri: "http://localhost:4000/auth",
client_secret: System.get_env("OAUTH2_CLIENT_SECRET"), client_secret: System.get_env("OAUTH2_CLIENT_SECRET"),
site: System.get_env("OAUTH2_SITE") site: System.get_env("OAUTH2_SITE")
],
github: [
client_id: System.get_env("GITHUB_CLIENT_ID"),
client_secret: System.get_env("GITHUB_CLIENT_SECRET"),
redirect_uri: "http://localhost:4000/auth"
] ]
], ],
tokens: [ tokens: [

View file

@ -27,9 +27,9 @@ config :ash_authentication,
redirect_uri: "http://localhost:4000/auth", redirect_uri: "http://localhost:4000/auth",
client_secret: "pretend client secret", client_secret: "pretend client secret",
site: "https://example.com/", site: "https://example.com/",
authorize_path: "/authorize", authorize_url: "https://example.com/authorize",
token_path: "/oauth/token", token_url: "https://example.com/oauth/token",
user_path: "/userinfo" user_url: "https://example.com/userinfo"
] ]
], ],
tokens: [ tokens: [

View file

@ -22,7 +22,7 @@ defmodule AshAuthentication.Dsl do
OptionsHelpers OptionsHelpers
} }
@type strategy :: :confirmation | :oauth2 | :password | :auth0 @type strategy :: :confirmation | :oauth2 | :password | :auth0 | :github
@shared_strategy_options [ @shared_strategy_options [
name: [ name: [
@ -193,7 +193,8 @@ defmodule AshAuthentication.Dsl do
entities: [ entities: [
strategy(:password), strategy(:password),
strategy(:oauth2), strategy(:oauth2),
strategy(:auth0) strategy(:auth0),
strategy(:github)
] ]
}, },
%Section{ %Section{
@ -319,15 +320,15 @@ defmodule AshAuthentication.Dsl do
args: [{:optional, :name, :oauth2}], args: [{:optional, :name, :oauth2}],
target: OAuth2, target: OAuth2,
modules: [ modules: [
:authorize_path, :authorize_url,
:client_id, :client_id,
:client_secret, :client_secret,
:identity_resource, :identity_resource,
:private_key, :private_key,
:redirect_uri, :redirect_uri,
:site, :site,
:token_path, :token_url,
:user_path :user_url
], ],
schema: schema:
OptionsHelpers.merge_schemas( OptionsHelpers.merge_schemas(
@ -415,59 +416,56 @@ defmodule AshAuthentication.Dsl do
""", """,
required: false required: false
], ],
authorize_path: [ authorize_url: [
type: @secret_type, type: @secret_type,
doc: """ doc: """
The API path to the OAuth2 authorize endpoint. The API url to the OAuth2 authorize endpoint.
Relative to the value of `site`. Relative to the value of `site`.
If not set, it defaults to `#{inspect(OAuth2.Default.default(:authorize_path))}`.
#{@secret_doc} #{@secret_doc}
Example: Example:
```elixir ```elixir
authorize_path fn _, _ -> {:ok, "/authorize"} end authorize_url fn _, _ -> {:ok, "https://exampe.com/authorize"} end
``` ```
""", """,
required: false required: true
], ],
token_path: [ token_url: [
type: @secret_type, type: @secret_type,
doc: """ doc: """
The API path to access the token endpoint. The API url to access the token endpoint.
Relative to the value of `site`. Relative to the value of `site`.
If not set, it defaults to `#{inspect(OAuth2.Default.default(:token_path))}`.
#{@secret_doc} #{@secret_doc}
Example: Example:
```elixir ```elixir
token_path fn _, _ -> {:ok, "/oauth_token"} end token_url fn _, _ -> {:ok, "https://example.com/oauth_token"} end
``` ```
""", """,
required: false required: true
], ],
user_path: [ user_url: [
type: @secret_type, type: @secret_type,
doc: """ doc: """
The API path to access the user endpoint. The API url to access the user endpoint.
Relative to the value of `site`. Relative to the value of `site`.
If not set, it defaults to `#{inspect(OAuth2.Default.default(:user_path))}`.
#{@secret_doc} #{@secret_doc}
Example: Example:
```elixir ```elixir
user_path fn _, _ -> {:ok, "/userinfo"} end user_url fn _, _ -> {:ok, "https://example.com/userinfo"} end
``` ```
""", """,
required: false required: true
], ],
private_key: [ private_key: [
type: @secret_type, type: @secret_type,
@ -587,7 +585,8 @@ defmodule AshAuthentication.Dsl do
], ],
@shared_strategy_options, @shared_strategy_options,
"Shared options" "Shared options"
) ),
auto_set_fields: [assent_strategy: Assent.Strategy.OAuth2]
} }
end end
@ -710,14 +709,24 @@ defmodule AshAuthentication.Dsl do
name: :auth0, name: :auth0,
args: [{:optional, :name, :auth0}], args: [{:optional, :name, :auth0}],
describe: "Auth0 authentication", describe: "Auth0 authentication",
auto_set_fields: [ auto_set_fields: strategy_fields(Assent.Strategy.Auth0, icon: :auth0)
authorization_params: [scope: "openid profile email"],
auth_method: :client_secret_post,
authorize_path: "/authorize",
token_path: "/oauth/token",
user_path: "/userinfo",
icon: :auth0
]
}) })
end end
def strategy(:github) do
:oauth2
|> strategy()
|> Map.merge(%{
name: :github,
args: [{:optional, :name, :github}],
describe: "GitHub authentication",
auto_set_fields: strategy_fields(Assent.Strategy.Github, icon: :github)
})
end
defp strategy_fields(strategy, params) do
params
|> Keyword.put(:assent_strategy, strategy)
|> strategy.default_config()
end
end end

View file

@ -223,9 +223,9 @@ defmodule AshAuthentication.Strategy.OAuth2 do
site: nil, site: nil,
auth_method: :client_secret_post, auth_method: :client_secret_post,
client_secret: nil, client_secret: nil,
authorize_path: nil, authorize_url: nil,
token_path: nil, token_url: nil,
user_path: nil, user_url: nil,
private_key: nil, private_key: nil,
redirect_uri: nil, redirect_uri: nil,
authorization_params: [], authorization_params: [],
@ -238,7 +238,8 @@ defmodule AshAuthentication.Strategy.OAuth2 do
provider: :oauth2, provider: :oauth2,
name: nil, name: nil,
resource: nil, resource: nil,
icon: nil icon: nil,
assent_strategy: Assent.Strategy.OAuth2
alias AshAuthentication.Strategy.OAuth2 alias AshAuthentication.Strategy.OAuth2
@ -254,9 +255,9 @@ defmodule AshAuthentication.Strategy.OAuth2 do
| :client_secret_jwt | :client_secret_jwt
| :private_key_jwt, | :private_key_jwt,
client_secret: secret, client_secret: secret,
authorize_path: secret, authorize_url: secret,
token_path: secret, token_url: secret,
user_path: secret, user_url: secret,
private_key: secret, private_key: secret,
redirect_uri: secret, redirect_uri: secret,
authorization_params: keyword, authorization_params: keyword,
@ -269,6 +270,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do
provider: atom, provider: atom,
name: atom, name: atom,
resource: module, resource: module,
icon: nil | atom icon: nil | atom,
assent_strategy: module
} }
end end

View file

@ -1,18 +0,0 @@
defmodule AshAuthentication.Strategy.OAuth2.Default do
@moduledoc """
Sets default values for values which can be configured at runtime and are not set.
"""
use AshAuthentication.Secret
@doc false
@impl true
@spec secret_for([atom], Ash.Resource.t(), keyword) :: {:ok, String.t()} | :error
def secret_for(path, _resource, _opts), do: path |> Enum.reverse() |> List.first() |> default()
@doc false
@spec default(atom) :: {:ok, String.t()}
def default(:authorize_path), do: {:ok, "/oauth/authorize"}
def default(:token_path), do: {:ok, "/oauth/access_token"}
def default(:user_path), do: {:ok, "/user"}
end

View file

@ -6,7 +6,6 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do
alias Ash.Error.Framework.AssumptionFailed alias Ash.Error.Framework.AssumptionFailed
alias AshAuthentication.{Errors, Info, Strategy, Strategy.OAuth2} alias AshAuthentication.{Errors, Info, Strategy, Strategy.OAuth2}
alias Assent.{Config, HTTPAdapter.Mint} alias Assent.{Config, HTTPAdapter.Mint}
alias Assent.Strategy.OAuth2, as: Assent
alias Plug.Conn alias Plug.Conn
import Ash.PlugHelpers, only: [get_actor: 1, get_tenant: 1] import Ash.PlugHelpers, only: [get_actor: 1, get_tenant: 1]
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2] import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
@ -22,7 +21,8 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do
def request(conn, strategy) do def request(conn, strategy) do
with {:ok, config} <- config_for(strategy), with {:ok, config} <- config_for(strategy),
{:ok, session_key} <- session_key(strategy), {:ok, session_key} <- session_key(strategy),
{:ok, %{session_params: session_params, url: url}} <- Assent.authorize_url(config) do {:ok, %{session_params: session_params, url: url}} <-
strategy.assent_strategy.authorize_url(config) do
conn conn
|> put_session(session_key, session_params) |> put_session(session_key, session_params)
|> put_resp_header("location", url) |> put_resp_header("location", url)
@ -46,7 +46,8 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do
session_params when is_map(session_params) <- get_session(conn, session_key), session_params when is_map(session_params) <- get_session(conn, session_key),
conn <- delete_session(conn, session_key), conn <- delete_session(conn, session_key),
config <- Config.put(config, :session_params, session_params), config <- Config.put(config, :session_params, session_params),
{:ok, %{user: user, token: token}} <- Assent.callback(config, conn.params), {:ok, %{user: user, token: token}} <-
strategy.assent_strategy.callback(config, conn.params),
action_opts <- action_opts(conn), action_opts <- action_opts(conn),
{:ok, user} <- {:ok, user} <-
register_or_sign_in_user( register_or_sign_in_user(
@ -70,9 +71,9 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do
with {:ok, client_id} <- fetch_secret(strategy, :client_id), with {:ok, client_id} <- fetch_secret(strategy, :client_id),
{:ok, site} <- fetch_secret(strategy, :site), {:ok, site} <- fetch_secret(strategy, :site),
{:ok, redirect_uri} <- build_redirect_uri(strategy), {:ok, redirect_uri} <- build_redirect_uri(strategy),
{:ok, authorize_url} <- build_uri(strategy, :authorize_path), {:ok, authorize_url} <- fetch_secret(strategy, :authorize_url),
{:ok, token_url} <- build_uri(strategy, :token_path), {:ok, token_url} <- fetch_secret(strategy, :token_url),
{:ok, user_url} <- build_uri(strategy, :user_path) do {:ok, user_url} <- fetch_secret(strategy, :user_url) do
config = config =
[ [
auth_method: strategy.auth_method, auth_method: strategy.auth_method,
@ -154,14 +155,4 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do
{:error, reason} {:error, reason}
end end
end end
defp build_uri(strategy, secret_name) do
with {:ok, site} <- fetch_secret(strategy, :site),
{:ok, uri} <- URI.new(site),
{:ok, path} <- fetch_secret(strategy, secret_name) do
path = Path.join(uri.path || "/", path)
{:ok, to_string(%URI{uri | path: path})}
end
end
end end

View file

@ -82,12 +82,7 @@ defmodule AshAuthentication.Strategy.OAuth2.Transformer do
end end
defp set_defaults(strategy) do defp set_defaults(strategy) do
default_secret = {OAuth2.Default, []}
strategy strategy
|> maybe_set_field(:authorize_path, default_secret)
|> maybe_set_field(:token_path, default_secret)
|> maybe_set_field(:user_path, default_secret)
|> maybe_set_field_lazy(:register_action_name, &:"register_with_#{&1.name}") |> maybe_set_field_lazy(:register_action_name, &:"register_with_#{&1.name}")
|> maybe_set_field_lazy(:sign_in_action_name, &:"sign_in_with_#{&1.name}") |> maybe_set_field_lazy(:sign_in_action_name, &:"sign_in_with_#{&1.name}")
end end

View file

@ -44,13 +44,13 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do
end end
defp transform_strategy(strategy) do defp transform_strategy(strategy) do
with :ok <- validate_secret(strategy, :authorize_path), with :ok <- validate_secret(strategy, :authorize_url),
:ok <- validate_secret(strategy, :client_id), :ok <- validate_secret(strategy, :client_id),
:ok <- validate_secret(strategy, :client_secret), :ok <- validate_secret(strategy, :client_secret),
:ok <- validate_secret(strategy, :redirect_uri), :ok <- validate_secret(strategy, :redirect_uri),
:ok <- validate_secret(strategy, :site), :ok <- validate_secret(strategy, :site),
:ok <- validate_secret(strategy, :token_path), :ok <- validate_secret(strategy, :token_url),
:ok <- validate_secret(strategy, :user_path) do :ok <- validate_secret(strategy, :user_url) do
validate_secret(strategy, :private_key, strategy.auth_method != :private_key_jwt) validate_secret(strategy, :private_key, strategy.auth_method != :private_key_jwt)
end end
end end
@ -71,7 +71,7 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do
DslError.exception( DslError.exception(
path: [:authentication, :strategies, :oauth2], path: [:authentication, :strategies, :oauth2],
message: message:
"Expected `#{inspect(option)}` to be either a string or a module which implements the `AshAuthentication.Sender` behaviour." "Expected `#{inspect(option)}` to be either a string or a module which implements the `AshAuthentication.Secret` behaviour."
)} )}
end end
end end

View file

@ -23,9 +23,4 @@ defmodule AshAuthentication.Strategy.OAuth2.PlugTest do
assert session.state =~ ~r/.+/ assert session.state =~ ~r/.+/
end end
end end
describe "callback/2" do
@tag skip: "not exactly sure the best way to test this"
test "it signs in or registers the user"
end
end end

View file

@ -10,6 +10,6 @@ defmodule Example.GenericOAuth2Change do
user_info = Changeset.get_argument(changeset, :user_info) user_info = Changeset.get_argument(changeset, :user_info)
changeset changeset
|> Changeset.change_attribute(:username, user_info["nickname"]) |> Changeset.change_attribute(:username, user_info["nickname"] || user_info["login"])
end end
end end

View file

@ -77,6 +77,17 @@ defmodule Example.User do
filter expr(username == get_path(^arg(:user_info), [:nickname])) filter expr(username == get_path(^arg(:user_info), [:nickname]))
end end
create :register_with_github do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
upsert? true
upsert_identity :username
change AshAuthentication.GenerateTokenChange
change Example.GenericOAuth2Change
change AshAuthentication.Strategy.OAuth2.IdentityChange
end
end end
graphql do graphql do
@ -147,9 +158,9 @@ defmodule Example.User do
redirect_uri &get_config/2 redirect_uri &get_config/2
client_secret &get_config/2 client_secret &get_config/2
site &get_config/2 site &get_config/2
authorize_path &get_config/2 authorize_url &get_config/2
token_path &get_config/2 token_url &get_config/2
user_path &get_config/2 user_url &get_config/2
authorization_params scope: "openid profile email" authorization_params scope: "openid profile email"
auth_method :client_secret_post auth_method :client_secret_post
identity_resource Example.UserIdentity identity_resource Example.UserIdentity
@ -160,9 +171,15 @@ defmodule Example.User do
redirect_uri &get_config/2 redirect_uri &get_config/2
client_secret &get_config/2 client_secret &get_config/2
site &get_config/2 site &get_config/2
authorize_path &get_config/2 authorize_url &get_config/2
token_path &get_config/2 token_url &get_config/2
user_path &get_config/2 user_url &get_config/2
end
github do
client_id &get_config/2
redirect_uri &get_config/2
client_secret &get_config/2
end end
end end
end end