-
Notifications
You must be signed in to change notification settings - Fork 147
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
00620b9
commit 58c4144
Showing
6 changed files
with
309 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |