diff --git a/README.md b/README.md index 83b491c4..c11f9480 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,33 @@ end Revocation will return `{:ok, %{}}` status even if the token is invalid. +### Token introspection + +Check access token or refresh token for validity and meta-data. [See RFC-7662](https://datatracker.ietf.org/doc/html/rfc7662) + +```elixir +# GET /oauth/introspect?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&token=ACCESS_TOKEN +# or +# GET /oauth/introspect?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&token=REFRESH_TOKEN +case ExOauth2Provider.Token.introspect(params, otp_app: :my_app) do + {:ok, introspection} -> # JSON response + {:error, error, http_status} -> # JSON response +end +``` + +Example `introspection` value: +```elixir +%{ + active: true, + client_id: "0f3e0eee9e70c6aa833bc03ba7e635e1842e92a82e14d7d2222221111", + exp: 1629563742, # not present for refresh tokens + iat: 1629556542, + scope: "read write", + sub: 1, + token_type: "bearer" +} +``` + ### Authorization code flow in a Single Page Application ExOauth2Provider doesn't support **implicit** grant flow. Instead you should set up an application with no client secret, and use the **Authorize code** grant flow. `client_secret` isn't required unless it has been set for the application. diff --git a/lib/ex_oauth2_provider/access_tokens/access_tokens.ex b/lib/ex_oauth2_provider/access_tokens/access_tokens.ex index bc35d24e..088562b6 100644 --- a/lib/ex_oauth2_provider/access_tokens/access_tokens.ex +++ b/lib/ex_oauth2_provider/access_tokens/access_tokens.ex @@ -34,6 +34,24 @@ defmodule ExOauth2Provider.AccessTokens do |> Config.repo(config).get_by(token: token) end + @doc """ + Gets a single access token belonging to an application. + + ## Examples + + iex> get_by_token_for(application, "c341a5c7b331ef076eb4954668d54f590e0009e06b81b100191aa22c93044f3d", otp_app: :my_app) + %OauthAccessToken{} + + iex> get_by_token_for(application, "75d72f326a69444a9287ea264617058dbbfe754d7071b8eef8294cbf4e7e0fdc", otp_app: :my_app) + nil + """ + @spec get_by_token_for(Application.t(), binary(), keyword()) :: AccessToken.t() | nil + def get_by_token_for(application, token, config \\ []) do + config + |> Config.access_token() + |> Config.repo(config).get_by(application_id: application.id, token: token) + end + @doc """ Gets an access token by the refresh token. diff --git a/lib/ex_oauth2_provider/oauth2/token.ex b/lib/ex_oauth2_provider/oauth2/token.ex index a47bce74..79b0081a 100644 --- a/lib/ex_oauth2_provider/oauth2/token.ex +++ b/lib/ex_oauth2_provider/oauth2/token.ex @@ -5,7 +5,8 @@ defmodule ExOauth2Provider.Token do alias ExOauth2Provider.{ Config, Token.Revoke, - Utils.Error} + Utils.Error, + Token.Introspect} alias Ecto.Schema @doc """ @@ -82,4 +83,9 @@ defmodule ExOauth2Provider.Token do """ @spec revoke(map(), keyword()) :: {:ok, Schema.t()} | {:error, map(), term()} def revoke(request, config \\ []), do: Revoke.revoke(request, config) + + @doc """ + Introspect an access or refresh token as per https://datatracker.ietf.org/doc/html/rfc7662 + """ + def introspect(params, config \\ []), do: Introspect.introspect(params, config) end diff --git a/lib/ex_oauth2_provider/oauth2/token/strategy/introspect.ex b/lib/ex_oauth2_provider/oauth2/token/strategy/introspect.ex new file mode 100644 index 00000000..111d43a6 --- /dev/null +++ b/lib/ex_oauth2_provider/oauth2/token/strategy/introspect.ex @@ -0,0 +1,87 @@ +defmodule ExOauth2Provider.Token.Introspect do + @moduledoc """ + Functions for dealing with token introspection. + """ + alias ExOauth2Provider.{ + AccessTokens, + Utils.Error, + Token.Utils, + Token.Utils.Response, + Mixin.Expirable, + Mixin.Revocable, + Config, + Schema + } + + def introspect(params, config \\ []) + + # 'token_type_hint' query param is not needed to guess if the token is an access or refresh token and can be safely ignored: https://datatracker.ietf.org/doc/html/rfc7662#section-2.1 + def introspect(%{"token" => _} = request, config) do + {:ok, %{request: request}} + |> Utils.load_client(config) + |> check_access_token(config) + |> check_refresh_token(config) + |> build_response(config) + end + + def introspect(_, _), do: Error.invalid_request() + + defp check_access_token({:ok, %{client: client, request: %{"token" => token}} = params}, config) do + access_token = AccessTokens.get_by_token_for(client, token, config) + + params = + if access_token == nil || Expirable.is_expired?(access_token) || + Revocable.is_revoked?(access_token) do + Map.merge(params, %{active: false}) + else + Map.merge(params, %{active: true, token: access_token, type: :access_token}) + end + + {:ok, params} + end + + defp check_access_token({:error, _} = req, _config), do: req + + defp check_refresh_token({:ok, %{client: client, active: false, request: %{"token" => token}} = params}, config) do + refresh_token = AccessTokens.get_by_refresh_token_for(client, token, config) + + params = + if refresh_token == nil || Revocable.is_revoked?(refresh_token) do + Map.merge(params, %{active: false}) + else + Map.merge(params, %{active: true, token: refresh_token, type: :refresh_token}) + end + + {:ok, params} + end + + defp check_refresh_token({:ok, %{active: true}} = req, _config), do: req + defp check_refresh_token({:error, _} = req, _config), do: req + + defp build_response({:ok, %{active: true, token: token, type: token_type}}, config) do + token = Config.repo(config).preload(token, :application) + + created_at = Schema.unix_time_for(token.inserted_at) + expires_at = + if token_type == :access_token do + created_at + token.expires_in + else # refresh tokens don't expire + nil + end + + # as defined in https://datatracker.ietf.org/doc/html/rfc7662#section-2.2 + {:ok, + %{ + active: true, + scope: token.scopes, + token_type: "bearer", + client_id: token.application.uid, + exp: expires_at, + iat: created_at, + sub: token.resource_owner_id + }} + end + + defp build_response({:ok, %{active: false}}, _), do: {:ok, %{active: false}} + defp build_response({:error, _} = params, config), do: Response.response(params, config) +end diff --git a/lib/ex_oauth2_provider/schema.ex b/lib/ex_oauth2_provider/schema.ex index ad9419d1..10f33552 100644 --- a/lib/ex_oauth2_provider/schema.ex +++ b/lib/ex_oauth2_provider/schema.ex @@ -91,4 +91,15 @@ defmodule ExOauth2Provider.Schema do def __timestamp__(type) do type.from_unix!(System.system_time(:microsecond), :microsecond) end + + def unix_time_for(%DateTime{} = datetime) do + DateTime.to_unix(datetime) + end + def unix_time_for(%NaiveDateTime{} = naive) do + DateTime.from_naive!(naive, "Etc/UTC") + |> unix_time_for() + end + def unix_time_for(date) when is_struct(date) do + date.__struct__.to_unix(date) + end end diff --git a/test/ex_oauth2_provider/access_tokens/access_tokens_test.exs b/test/ex_oauth2_provider/access_tokens/access_tokens_test.exs index 6ebe0c07..ac4acef2 100644 --- a/test/ex_oauth2_provider/access_tokens/access_tokens_test.exs +++ b/test/ex_oauth2_provider/access_tokens/access_tokens_test.exs @@ -17,6 +17,20 @@ defmodule ExOauth2Provider.AccessTokensTest do assert id == access_token.id end + test "get_by_token_for/2", %{user: user, application: application} do + {:ok, access_token} = AccessTokens.create_token(user, %{application: application}, otp_app: :ex_oauth2_provider) + + assert %OauthAccessToken{id: id} = AccessTokens.get_by_token_for(application, access_token.token, otp_app: :ex_oauth2_provider) + assert id == access_token.id + end + + test "get_by_token_for/2 different application", %{user: user, application: application} do + {:ok, access_token} = AccessTokens.create_token(user, %{application: application}, otp_app: :ex_oauth2_provider) + + other_application = Fixtures.application(resource_owner: user, uid: "other",) + assert AccessTokens.get_by_token_for(other_application, access_token.token, otp_app: :ex_oauth2_provider) == nil + end + test "get_by_refresh_token/2", %{user: user} do assert {:ok, access_token} = AccessTokens.create_token(user, %{use_refresh_token: true}, otp_app: :ex_oauth2_provider) diff --git a/test/ex_oauth2_provider/oauth2/token/strategy/introspection_test.exs b/test/ex_oauth2_provider/oauth2/token/strategy/introspection_test.exs new file mode 100644 index 00000000..cab604b3 --- /dev/null +++ b/test/ex_oauth2_provider/oauth2/token/strategy/introspection_test.exs @@ -0,0 +1,239 @@ +defmodule ExOauth2Provider.Token.Strategy.IntrospectionTest do + use ExOauth2Provider.TestCase + + alias ExOauth2Provider.{AccessTokens, Token, Schema} + alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} + + @client_id "Jf5rM8hQBc" + @client_secret "secret" + @invalid_client_error %{ + error: :invalid_client, + error_description: + "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." + } + + setup do + user = Fixtures.resource_owner() + + application = + Fixtures.application( + resource_owner: user, + uid: @client_id, + secret: @client_secret, + scopes: "app:read app:write" + ) + + access_token = + Fixtures.access_token( + resource_owner: user, + application: application, + use_refresh_token: true, + scopes: "app:read" + ) + + valid_request = %{ + "client_id" => @client_id, + "client_secret" => @client_secret, + "token" => access_token.token + } + + {:ok, + %{ + user: user, + application: application, + access_token: access_token, + valid_request: valid_request + }} + end + + test "#introspect/2 error when invalid client", %{valid_request: valid_request} do + params = Map.merge(valid_request, %{"client_id" => "invalid"}) + + assert Token.introspect(params, otp_app: :ex_oauth2_provider) == + {:error, @invalid_client_error, :unprocessable_entity} + end + + test "#introspect/2 error when invalid secret", %{valid_request: valid_request} do + params = Map.merge(valid_request, %{"client_secret" => "invalid"}) + + assert Token.introspect(params, otp_app: :ex_oauth2_provider) == + {:error, @invalid_client_error, :unprocessable_entity} + end + + test "#introspect/2 non-existing token", %{valid_request: valid_request} do + params = Map.merge(valid_request, %{"token" => "invalid"}) + + assert Token.introspect(params, otp_app: :ex_oauth2_provider) == {:ok, %{active: false}} + end + + test "#introspect/2 access token", %{ + valid_request: valid_request, + access_token: access_token, + user: user + } do + created_at = Schema.unix_time_for(access_token.inserted_at) + + expected_introspection = + {:ok, + %{ + active: true, + client_id: @client_id, + exp: created_at + access_token.expires_in, + iat: created_at, + scope: access_token.scopes, + sub: user.id, + token_type: "bearer" + }} + + assert Token.introspect(valid_request, otp_app: :ex_oauth2_provider) == expected_introspection + end + + test "#introspect/2 access token owned by another application", %{ + valid_request: valid_request, + access_token: access_token + } do + new_application = Fixtures.application(uid: "new_app", client_secret: "new") + QueryHelpers.change!(access_token, application_id: new_application.id) + + assert Token.introspect(valid_request, otp_app: :ex_oauth2_provider) == + {:ok, %{active: false}} + end + + test "#introspect/2 refresh token", %{ + valid_request: valid_request, + access_token: access_token, + user: user + } do + params = Map.merge(valid_request, %{"token" => access_token.refresh_token}) + + created_at = Schema.unix_time_for(access_token.inserted_at) + + expected_introspection = + {:ok, + %{ + active: true, + client_id: @client_id, + exp: nil, + iat: created_at, + scope: access_token.scopes, + sub: user.id, + token_type: "bearer" + }} + + assert Token.introspect(params, otp_app: :ex_oauth2_provider) == expected_introspection + end + + describe "access token with refresh token disabled" do + setup %{ + application: application, + valid_request: valid_request, + user: user + } do + access_token = + Fixtures.access_token( + resource_owner: user, + application: application, + use_refresh_token: false, + scopes: "app:read" + ) + + valid_request = Map.merge(valid_request, %{"token" => access_token.token}) + + {:ok, + %{ + valid_request: valid_request, + access_token: access_token + }} + end + + test "#introspect/2", %{ valid_request: valid_request } do + assert {:ok, %{active: true}} = Token.introspect(valid_request, otp_app: :ex_oauth2_provider) + end + end + + describe "with expired token" do + setup %{ + application: application, + access_token: access_token, + valid_request: valid_request, + user: user + } do + expired_token = + Fixtures.access_token( + resource_owner: user, + application: application, + use_refresh_token: true, + scopes: "app:read", + expires_in: -1000 + ) + + {:ok, + %{ + valid_request: valid_request, + expired_token: expired_token + }} + end + + test "#introspect/2 expired access token", %{ + valid_request: valid_request, + expired_token: expired_token + } do + params = Map.merge(valid_request, %{"token" => expired_token.token}) + + assert Token.introspect(params, otp_app: :ex_oauth2_provider) == {:ok, %{active: false}} + end + + test "#introspect/2 expired refresh token", %{ + valid_request: valid_request, + expired_token: expired_token + } do + params = Map.merge(valid_request, %{"token" => expired_token.refresh_token}) + + assert {:ok, %{active: true}} = Token.introspect(params, otp_app: :ex_oauth2_provider) + end + end + + describe "with revoked token" do + setup %{ + application: application, + access_token: access_token, + valid_request: valid_request, + user: user + } do + revoked_token = + Fixtures.access_token( + resource_owner: user, + application: application, + use_refresh_token: true, + scopes: "app:read" + ) + + revoked_token = AccessTokens.revoke!(revoked_token, otp_app: :ex_oauth2_provider) + + {:ok, + %{ + valid_request: valid_request, + revoked_token: revoked_token + }} + end + + test "#introspect/2 revoked access token", %{ + valid_request: valid_request, + revoked_token: revoked_token, + user: user + } do + params = Map.merge(valid_request, %{"token" => revoked_token.token}) + + assert Token.introspect(params, otp_app: :ex_oauth2_provider) == {:ok, %{active: false}} + end + + test "#introspect/2 revoked refresh token", %{ + valid_request: valid_request, + revoked_token: revoked_token + } do + params = Map.merge(valid_request, %{"token" => revoked_token.refresh_token}) + + assert Token.introspect(params, otp_app: :ex_oauth2_provider) == {:ok, %{active: false}} + end + end +end