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

Token introspection #90

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions lib/ex_oauth2_provider/access_tokens/access_tokens.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 7 additions & 1 deletion lib/ex_oauth2_provider/oauth2/token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ defmodule ExOauth2Provider.Token do
alias ExOauth2Provider.{
Config,
Token.Revoke,
Utils.Error}
Utils.Error,
Token.Introspect}
alias Ecto.Schema

@doc """
Expand Down Expand Up @@ -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
87 changes: 87 additions & 0 deletions lib/ex_oauth2_provider/oauth2/token/strategy/introspect.ex
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions lib/ex_oauth2_provider/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions test/ex_oauth2_provider/access_tokens/access_tokens_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading