Skip to content

Commit

Permalink
Add JWT support
Browse files Browse the repository at this point in the history
  • Loading branch information
danielberkompas committed Apr 16, 2018
1 parent 00620b9 commit 58c4144
Show file tree
Hide file tree
Showing 6 changed files with 309 additions and 0 deletions.
32 changes: 32 additions & 0 deletions lib/ex_twilio/ext/map.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule ExTwilio.Ext.Map do
@moduledoc "Additional helper functions for working with maps"

@type key :: atom | String.t()

@doc """
Puts a given key/value pair into a map, if the value is not `false` or `nil`.
"""
@spec put_if(map, key, any) :: map
def put_if(map, _key, value) when value in [nil, false] do
map
end

def put_if(map, key, value) do
Map.put(map, key, value)
end

@doc """
Validates that a function returns true on the given map field, otherwise
raises an error.
"""
@spec validate!(map, key, function, message :: String.t()) :: map | no_return
def validate!(map, field, fun, message) do
value = Map.get(map, field)

if fun.(value) do
map
else
raise ArgumentError, "#{inspect(field)} #{message}, was: #{inspect(value)}"
end
end
end
114 changes: 114 additions & 0 deletions lib/ex_twilio/jwt/access_token.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
defmodule ExTwilio.JWT.AccessToken do
@moduledoc """
A Twilio JWT access token, as described in the Twilio docs:
https://www.twilio.com/docs/iam/access-tokens
"""

alias ExTwilio.JWT.Grant
alias ExTwilio.Ext

@enforce_keys [:account_sid, :api_key, :api_secret, :identity, :grants, :expires_in]

defstruct token_identifier: nil,
account_sid: nil,
api_key: nil,
api_secret: nil,
identity: nil,
grants: [],
expires_in: nil

@type t :: %__MODULE__{
account_sid: String.t(),
api_key: String.t(),
api_secret: String.t(),
identity: String.t(),
grants: [ExTwilio.JWT.Grant.t()],
expires_in: integer
}

@doc """
Creates a new JWT access token.
## Example
AccessToken.new(
account_sid: "account_sid",
api_key: "api_key",
api_secret: "secret",
identity: "user@email.com",
expires_in: 86_400,
grants: [AccessToken.ChatGrant.new(service_sid: "sid")]
)
"""
@spec new(attrs :: Keyword.t()) :: t
def new(attrs \\ []) do
struct(__MODULE__, attrs)
end

@doc """
Converts an access token into a string JWT.
Will raise errors if the `token` does not have all the required fields.
## Example
token =
AccessToken.new(
account_sid: "account_sid",
api_key: "api_key",
api_secret: "secret",
identity: "user@email.com",
expires_in: 86_400,
grants: [AccessToken.ChatGrant.new(service_sid: "sid")]
)
AccessToken.to_jwt!(token)
# => "eyJhbGciOiJIUzI1NiIsImN0eSI6InR3aWxpby1mcGE7dj0xIiwidHlwIjoiSldUIn0.eyJleHAiOjE1MjM5MTIxODgsImdyYW50cyI6eyJjaGF0Ijp7ImVuZHBvaW50X2lkIjpudWxsLCJzZXJ2aWNlX3NpZCI6InNpZCJ9LCJpZGVudGl0eSI6InVzZXJAZW1haWwuY29tIn0sImlhdCI6MTUyMzkwNDk4OCwibmJmIjoxNTIzOTA0OTg3fQ.M_5dsj1VWBrIZKvcIdygSpmiMsrZdkplYYNjxEhBHk0"
"""
@spec to_jwt!(t) :: String.t() | no_return
def to_jwt!(token) do
token =
token
|> Ext.Map.validate!(:account_sid, &is_binary/1, "must be a binary")
|> Ext.Map.validate!(:api_key, &is_binary/1, "must be a binary")
|> Ext.Map.validate!(:api_secret, &is_binary/1, "must be a binary")
|> Ext.Map.validate!(:identity, &is_binary/1, "must be a binary")
|> Ext.Map.validate!(:grants, &list_of_grants?/1, "must be a list of grants")
|> Ext.Map.validate!(:expires_in, &is_integer/1, "must be an integer")

Joken.token()
|> Joken.with_jti(token.token_identifier || "#{token.api_key}-#{random_str()}")
|> Joken.with_iss(token.api_key)
|> Joken.with_sub(token.account_sid)
|> Joken.with_nbf(DateTime.utc_now() |> DateTime.to_unix())
|> Joken.with_exp(token.expires_in)
|> Joken.with_claims(claims(token))
|> Joken.with_header_args(%{"typ" => "JWT", "alg" => "HS256", "cty" => "twilio-fpa;v=1"})
|> Joken.with_signer(Joken.hs256(token.api_secret))
|> Joken.sign()
|> Joken.get_compact()
end

defp list_of_grants?(grants) when is_list(grants) do
Enum.all?(grants, &Grant.impl_for(&1))
end

defp list_of_grants?(_other), do: false

defp claims(token) do
grants =
Enum.reduce(token.grants, %{"identity" => token.identity}, fn grant, acc ->
Map.put(acc, Grant.type(grant), Grant.attrs(grant))
end)

%{"grants" => grants}
end

defp random_str do
16
|> :crypto.strong_rand_bytes()
|> Base.encode16()
|> String.downcase()
end
end
20 changes: 20 additions & 0 deletions lib/ex_twilio/jwt/access_token/chat_grant.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule ExTwilio.JWT.AccessToken.ChatGrant do
@enforce_keys [:service_sid]
defstruct service_sid: nil, endpoint_id: nil, deployment_role_sid: nil, push_credential_sid: nil

def new(attrs \\ []) do
struct(__MODULE__, attrs)
end

defimpl ExTwilio.JWT.Grant do
alias ExTwilio.Ext

def type(_grant), do: "chat"

def attrs(grant) do
%{"service_sid" => grant.service_sid, "endpoint_id" => grant.endpoint_id}
|> Ext.Map.put_if("deployment_role_sid", grant.deployment_role_sid)
|> Ext.Map.put_if("push_credential_sid", grant.push_credential_sid)
end
end
end
4 changes: 4 additions & 0 deletions lib/ex_twilio/jwt/grant.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
defprotocol ExTwilio.JWT.Grant do
def type(grant)
def attrs(grant)
end
53 changes: 53 additions & 0 deletions test/ex_twilio/jwt/access_token/chat_grant_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
defmodule ExTwilio.JWT.AccessToken.ChatGrantTest do
use ExUnit.Case

alias ExTwilio.JWT.AccessToken.ChatGrant
alias ExTwilio.JWT.Grant

describe "__struct__" do
test "enforces :service_sid" do
assert_raise ArgumentError, fn ->
Code.eval_string("%ExTwilio.JWT.AccessToken.ChatGrant{}")
end

assert %ChatGrant{service_sid: "sid"}
end
end

describe ".new/1" do
test "accepts all attributes" do
assert ChatGrant.new(
service_sid: "sid",
endpoint_id: "id",
deployment_role_sid: "sid",
push_credential_sid: "sid"
) == %ChatGrant{
service_sid: "sid",
endpoint_id: "id",
deployment_role_sid: "sid",
push_credential_sid: "sid"
}
end
end

test "implements ExTwilio.JWT.Grant" do
assert Grant.type(%ChatGrant{service_sid: "sid"}) == "chat"

assert Grant.attrs(%ChatGrant{service_sid: "sid"}) == %{
"service_sid" => "sid",
"endpoint_id" => nil
}

assert Grant.attrs(%ChatGrant{service_sid: "sid", deployment_role_sid: "sid"}) == %{
"service_sid" => "sid",
"endpoint_id" => nil,
"deployment_role_sid" => "sid"
}

assert Grant.attrs(%ChatGrant{service_sid: "sid", push_credential_sid: "sid"}) == %{
"service_sid" => "sid",
"endpoint_id" => nil,
"push_credential_sid" => "sid"
}
end
end
86 changes: 86 additions & 0 deletions test/ex_twilio/jwt/access_token_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule ExTwilio.JWT.AccessTokenTest do
use ExUnit.Case, async: true

alias ExTwilio.JWT.AccessToken

describe ".new/1" do
test "accepts all struct keys" do
assert AccessToken.new(
token_identifier: "id",
account_sid: "sid",
api_key: "sid",
api_secret: "secret",
identity: "user@email.com",
grants: [AccessToken.ChatGrant.new(service_sid: "sid")],
expires_in: 86_400
) == %AccessToken{
token_identifier: "id",
account_sid: "sid",
api_key: "sid",
api_secret: "secret",
identity: "user@email.com",
grants: [%AccessToken.ChatGrant{service_sid: "sid"}],
expires_in: 86_400
}
end
end

describe ".to_jwt!/1" do
test "produces a valid Twilio JWT" do
token =
AccessToken.new(
account_sid: "sid",
api_key: "sid",
api_secret: "secret",
identity: "user@email.com",
grants: [AccessToken.ChatGrant.new(service_sid: "sid")],
expires_in: 86_400
)
|> AccessToken.to_jwt!()
|> Joken.token()

assert {:ok, claims} = Joken.verify!(token, Joken.hs256("secret"))
assert_in_delta unix_now(), claims["iat"], 10
assert_in_delta unix_now(), claims["nbf"], 10
assert_in_delta unix_now(), claims["exp"], 86_400

assert claims["grants"] == %{
"chat" => %{"endpoint_id" => nil, "service_sid" => "sid"},
"identity" => "user@email.com"
}
end

test "validates binary keys" do
for invalid <- [123, 'sid', nil, false],
field <- [:account_sid, :api_key, :api_secret, :identity] do
assert_raise ArgumentError, fn ->
[{field, invalid}]
|> AccessToken.new()
|> AccessToken.to_jwt!()
end
end
end

test "validates :grants" do
assert_raise ArgumentError, fn ->
[grants: [%{}]]
|> AccessToken.new()
|> AccessToken.to_jwt!()
end
end

test "validates :expires_in" do
for invalid <- [nil, false, "1 hour"] do
assert_raise ArgumentError, fn ->
[expires_in: invalid]
|> AccessToken.new()
|> AccessToken.to_jwt!()
end
end
end
end

defp unix_now do
DateTime.utc_now() |> DateTime.to_unix()
end
end

0 comments on commit 58c4144

Please sign in to comment.