pow-auth / assent

Multi-provider framework in Elixir
https://powauth.com
MIT License
391 stars 45 forks source link

MSIS9691: Received invalid OAuth request. The Basic Authorization header must be Base64 encoded. #23

Closed manuscrypt closed 4 years ago

manuscrypt commented 4 years ago

I am trying to use PowAssent with OAuth2 strategy against an ADFS-provider and it's almost working, but even though I can see the access_token as a param in the request, something is not quite right yet. Probably I am just missing something relevant. I thought, I'd report just in case anyone else faces this (ungoogleable) error code in the subject.

Glad for any pointers, let me know, if you need more info. Thank you and cheers!

P.S. I did this: PPS: I realize now that this might belong into the Assent repo 🤕

scope "/" do
    pipe_through :skip_csrf_protection

    post "/auth/:provider/callback", PowAssent.Phoenix.AuthorizationController, :callback
end

================================================ warning: This request will NOT be verified for valid SSL certificate (assent) lib/assent/http_adapter/httpc.ex:22: Assent.HTTPAdapter.Httpc.request/5 (assent) lib/assent/strategy.ex:42: Assent.Strategy.request/5 (assent) lib/assent/strategies/oauth2.ex:222: Assent.Strategy.OAuth2.get_access_token/2 (assent) lib/assent/strategies/oauth2.ex:119: Assent.Strategy.OAuth2.callback/3 (assent) lib/assent/strategies/oauth2/base.ex:69: Assent.Strategy.OAuth2.Base.callback/3 (pow_assent) lib/pow_assent/plug.ex:74: PowAssent.Plug.callback/4 (pow_assent) lib/pow_assent/phoenix/controllers/authorization_controller.ex:39: PowAssent.Phoenix.AuthorizationController.process_callback/2 (pow) lib/pow/phoenix/controllers/controller.ex:99: Pow.Phoenix.Controller.action/3 (pow_assent) lib/pow_assent/phoenix/controllers/authorization_controller.ex:1: PowAssent.Phoenix.AuthorizationController.action/2 (pow_assent) lib/pow_assent/phoenix/controllers/authorization_controller.ex:1: PowAssent.Phoenix.AuthorizationController.phoenix_controller_pipeline/2 (phoenix) lib/phoenix/router.ex:288: Phoenix.Router.call/2 (my_app) lib/my_app_web/endpoint.ex:1: MyAppWeb.Endpoint.plug_builder_call/2 (my_app) lib/plug/debugger.ex:122: MyAppWeb.Endpoint."call (overridable 3)"/2 (my_app) lib/my_app_web/endpoint.ex:1: MyAppWeb.Endpoint.call/2 (phoenix) lib/phoenix/endpoint/cowboy2_handler.ex:42: Phoenix.Endpoint.Cowboy2Handler.init/4 (cowboy) c:/src/my_app/deps/cowboy/src/cowboy_handler.erl:41: :cowboy_handler.execute/2 (cowboy) c:/src/my_app/deps/cowboy/src/cowboy_stream_h.erl:320: :cowboy_stream_h.execute/3

[error] #PID<0.1578.0> running SmartpowerWeb.Endpoint (connection #PID<0.1549.0>, stream id 5) terminated

Server: localhost:4200 (http) Request: POST /auth/oauth2/callback (exit) an exception was raised: (Assent.RequestError) Server responded with status: 400

Headers: cache-control: no-store date: Mon, 11 Nov 2019 21:27:16 GMT pragma: no-cache server: Microsoft-HTTPAPI/2.0 Microsoft-HTTPAPI/2.0 content-length: 146 content-type: application/json;charset=UTF-8

Body: %{"error" => "invalid_request", "error_description" => "MSIS9691: Received invalid OAuth request. The Basic Authorization header must be Base64 encoded."}

         (pow_assent) lib/pow_assent/phoenix/controllers/authorization_controller.ex:190: PowAssent.Phoenix.AuthorizationController.handle_strategy_error/1
    (pow) lib/pow/phoenix/controllers/controller.ex:99: Pow.Phoenix.Controller.action/3
    (pow_assent) lib/pow_assent/phoenix/controllers/authorization_controller.ex:1: PowAssent.Phoenix.AuthorizationController.action/2
    (pow_assent) lib/pow_assent/phoenix/controllers/authorization_controller.ex:1: PowAssent.Phoenix.AuthorizationController.phoenix_controller_pipeline/2
    (phoenix) lib/phoenix/router.ex:288: Phoenix.Router.__call__/2
    (myapp) lib/myapp_web/endpoint.ex:1: MyAppWeb.Endpoint.plug_builder_call/2
    (myapp) lib/plug/debugger.ex:122: MyAppWeb.Endpoint."call (overridable 3)"/2
    (myapp) lib/myapp_web/endpoint.ex:1: MyAppWeb.Endpoint.call/2
    (phoenix) lib/phoenix/endpoint/cowboy2_handler.ex:42: Phoenix.Endpoint.Cowboy2Handler.init/4
    (cowboy) c:/src/myapp/deps/cowboy/src/cowboy_handler.erl:41: :cowboy_handler.execute/2
    (cowboy) c:/src/myapp/deps/cowboy/src/cowboy_stream_h.erl:320: :cowboy_stream_h.execute/3
    (cowboy) c:/src/myapp/deps/cowboy/src/cowboy_stream_h.erl:302: :cowboy_stream_h.request_process/3
    (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
manuscrypt commented 4 years ago

I see that i tries to fetch the access_token, but the access_token was already in the params of the first callback, so I guess, it's technically not a full authorization code flow?

I tried several combinations, this is what it's at and i am happy it reaches the callback in the Authorization-Controller...

  authorization_params: [
        response_type: "token id_token code",
        resource: "xxx",
        grant_type: "authorization_code",
        response_mode: "form_post"
      ],
danschultzer commented 4 years ago

Transferred to assent repo, as it is indeed an assent issue.

Are you using :client_secret_post auth method rather than :client_secret_basic? With :client_secret_basic the authorization header is base64 encoded: https://github.com/pow-auth/assent/blob/v0.1.4/lib/assent/strategies/oauth2.ex#L142-L152

Also FYI the next release of PowAssent will have built-in support for POST callback, though adding a POST route is pretty much the same thing.

manuscrypt commented 4 years ago

Thank you for your feedback. I played around with it some more and I have two notable points:

  1. I only get the original error, when I use no auth_method or :client_secret_basic. (even tho I see it clearly done in the code you linked). when using e.g. :client_secret_jwt, it complains about invalid credentials, which leads me to point 2.

  2. I don't have a client_secret for this provider, but it seems, that I don't need one, as I get the access_token I want in the very first callback.

it's as if it needed another implementation for get_access_token with a different match, something like: defp get_access_token(config, %{"acccess_token" => token}) do

which returns/uses the token directly, when its in the params. but i am just guessing here.

I am using a CustomProvider with use Assent.Strategy.OAuth2.Base. I return the following (redacted) config from default_config:

[
  client_id: "redacted guid",
  client_secret: "",
  auth_method: :client_secret_jwt,
  site: "https://login.redacted.com/adfs",
  authorize_url: "https://login.redacted.com/adfs/oauth2/authorize",
  token_url: "https://login.redacted.com/adfs/oauth2/token",
  authorization_params: [
    response_type: "token id_token code", //<-- here asking for ALL the codes
    resource: "redacted guid",
    grant_type: "password", //or authorization_code
    response_mode: "form_post" //this is needed, no luck with :query or :fragment
  ],
  resource: "same redacted guid as above",
  nonce:
    16
    |> :crypto.strong_rand_bytes()
    |> Base.encode64(padding: false),  //had `true` here  as well
  strategy: Assent.Strategy.OAuth2
]
danschultzer commented 4 years ago

Yeah, you are right that no secret should be used, I'll relax the OAuth2 client to not use any auth method if there is no :auth_method defined.

In the meanwhile, could you try use :client_secret_post as the :auth_method? It seems that it's the way to deal with it: https://github.com/nordvall/TokenClient/wiki/OAuth-2-Authorization-Code-grant-in-ADFS

Your config also seems to mix in some OIDC stuff, that isn't used or necessary in OAuth 2.0. This should be enough for OAuth 2.0:

[
  client_id: "redacted guid",
  client_secret: "",
  auth_method: :client_secret_post,
  site: "https://login.redacted.com/adfs",
  authorize_url: "oauth2/authorize",
  token_url: "oauth2/token",
  authorization_params: [
    response_type: "code",
    resource: "redacted guid",
    response_mode: "form_post"
  ],
  strategy: Assent.Strategy.OAuth2
]

But you can also use Assent.Strategy.OIDC instead:

[
  client_id: "redacted guid",
  client_secret: "",
  auth_method: :client_secret_post,
  site: "https://login.redacted.com/adfs",
  authorization_params: [
    response_type: "id_token code",
    resource: "redacted guid",
    response_mode: "form_post"
  ],
  strategy: Assent.Strategy.OIDC
]
danschultzer commented 4 years ago

Just added a fix in #24. Try use the master branch of assent in mix.exs: {:assent, github: "pow-auth/assent", override: true}.

And use this config setting:

[
  client_id: "redacted guid",
  site: "https://login.redacted.com/adfs",
  authorize_url: "oauth2/authorize",
  token_url: "oauth2/token",
  authorization_params: [
    response_type: "code",
    resource: "redacted guid",
    response_mode: "form_post"
  ],
  strategy: Assent.Strategy.OAuth2
]

Or if you wish to use OIDC:

[
  client_id: "redacted guid",
  site: "https://login.redacted.com/adfs",
  authorization_params: [
    response_type: "id_token code",
    resource: "redacted guid",
    response_mode: "form_post"
  ],
  strategy: Assent.Strategy.OIDC
]
manuscrypt commented 4 years ago

Very cool, thank you. I did as you suggested and I am now getting the following error:

Assent.Config.MissingKeyError at POST /auth/schultzer/callback
Key `:user_url` not found in config

This information I don't have (this is a URL at the Auth-Providers site, right?). Maybe I can find it, though, I'll keep searching... but I should not actually be storing any user-identifying information.

When I try OIDC, it behaves a bit like before, asking for the :user_secret, when I supply an empty one, it complains about the Base64-encoding.

But I was able to work around it by providing my own (horrible) strategy:

the gist:

  def authorize_url(config) do
    with {:ok, redirect_uri} <- Keyword.fetch(config, :redirect_uri),
         {:ok, site} <- Keyword.fetch(config, :site),
         {:ok, client_id} <- Keyword.fetch(config, :client_id) do
      state = gen_state()
      params = authorization_params(config, client_id, state, redirect_uri)
      authorize_url = Keyword.get(config, :authorize_url, "/oauth/authorize")
      url = to_url(site, authorize_url, params)
      {:ok, %{url: url, session_params: %{state: state}}}
    end
  end

  defp authorization_params(config, client_id, state, redirect_uri) do
    params = Keyword.get(config, :authorization_params, [])

    [response_type: "access_token", client_id: client_id, state: state, redirect_uri: redirect_uri]
    |> Keyword.merge(params)
    |> List.keysort(0)
  end

  def callback(config, params, strategy \\ __MODULE__) do
    token = params["access_token"]
    {:ok, claims} = Joken.peek_claims(token)
    {:ok, %{user: claims, token: claims}}
  end

Not sure, if I return the right stuff, tho...

danschultzer commented 4 years ago

Oh right, the assent strategies expects a user url so the user info can be fetched.

You can create a custom provider module that overrides the get_user/2 method. This is how the user is fetched using the id_token with OIDC:

defmodule MyApp.ADFS do
  use Assent.Strategy.OIDC.Base

  @impl true
  def default_config(config) do
    [
      site: "https://login.redacted.com/adfs",
      authorization_params: [
        response_type: "id_token code",
        response_mode: "form_post"
      ]
    ]
  end

  @impl true
  def normalize(_config, user), do: {:ok, user}

  @impl true
  def get_user(config, token) do
    case Helpers.verify_jwt(token["id_token"], nil, config) do
      {:ok, jwt}      -> {:ok, jwt.claims}
      {:error, error} -> {:error, error}
    end
  end
end
[
  client_id: "redacted guid",
  authorization_params: [
    response_type: "id_token code",
    resource: "redacted guid",
    response_mode: "form_post"
  ],
  strategy: MyApp.ADFS
]

And if you want to use OAuth2 strategy:

defmodule MyApp.ADFS do
  use Assent.Strategy.OAuth2.Base

  @impl true
  def default_config(config) do
    [
      site: "https://login.redacted.com/adfs",
      authorize_url: "/oauth2/authorize",
      token_url: "/oauth2/token",
      authorization_params: [
        response_type: "code",
        response_mode: "form_post"
      ]
    ]
  end

  @impl true
  def normalize(_config, user), do: {:ok, user}

  @impl true
  def get_user(_config, _token), do: {:ok, %{}}

  # Or if the access token has the claims:
  #
  # @impl true
  # def get_user(config, token) do
  #   case Helpers.verify_jwt(token["access_token"], nil, config) do
  #     {:ok, jwt}      -> {:ok, jwt.claims}
  #     {:error, error} -> {:error, error}
  #   end
  # end
end
[
  client_id: "redacted guid",
  authorization_params: [
    response_type: "code",
    resource: "redacted guid",
    response_mode: "form_post"
  ],
  strategy: MyApp.ADFS
]
danschultzer commented 4 years ago

Oh, btw using PowAssent it's expected that a sub is returned, so the second example you should definitely use the commented get_user/2 method so you can at least get the id for the user.

manuscrypt commented 4 years ago

Thank you very much again for taking the time to help me integrate with my odd auth-endpoint. I am able to authenticate with ADFS using a custom strategy, containing bits and pieces of the code you provided. I very much appreciate your efforts, thanks again.

danschultzer commented 4 years ago

v0.1.5 released with #24 🚀