Skip to content

Commit e0af1a7

Browse files
committed
Cast user claim values
1 parent 2dc60fe commit e0af1a7

12 files changed

+236
-24
lines changed

CHANGELOG.md

+12
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66

77
### Breaking changes
88

9+
* `Assent.Strategy.Auth.callback/2` now encodes `updated_at` as an integer instead of string
10+
* `Assent.Strategy.Basecamp.callback/2` now encodes `sub` as a string instead of an integer
11+
* `Assent.Strategy.Github.callback/2` now encodes `sub` as a string instead of an integer
12+
* `Assent.Strategy.Gitlab.callback/2` now encodes `sub` as a string instead of an integer
13+
* `Assent.Strategy.Strava.callback/2` now encodes `sub` as a string instead of an integer
14+
* `Assent.Strategy.Telegram.callback/2` now encodes `sub` as a string instead of an integer
15+
* `Assent.Strategy.Twitter.callback/2` now encodes `sub` as a string instead of an integer
16+
* `Assent.Strategy.VK.callback/2` now encodes `sub` as a string instead of an integer
917
* `:site` configuration option removed, use `:base_url` instead
1018
* `Assent.Strategy.OAuth2.authorize_url/2` no longer allows `:state` in `:authorization_params`
1119
* `Assent.Strategy.decode_response/2`removed, use `Assent.HTTPAdapter.decode_response/2` instead
@@ -14,6 +22,10 @@
1422
* `Assent.HTTPAdapter.Mint` removed
1523
* `Assent.Config` removed
1624

25+
### Changes
26+
27+
* `Assent.Strategy.normalize_userinfo/2` now casts the user claims per OpenID specification
28+
1729
## v0.2
1830

1931
The CHANGELOG for v0.2 releases can be found [in the v0.2 branch](https://github.com/pow-auth/assent/blob/v0.2/CHANGELOG.md).

lib/assent.ex

+24
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,30 @@ defmodule Assent do
132132
end
133133
end
134134

135+
defmodule CastClaimsError do
136+
defexception [:claims]
137+
138+
def message(exception) do
139+
"""
140+
The following claims couldn't be cast:
141+
142+
#{exception.claims |> map_claims() |> List.flatten() |> Enum.join("\n")}
143+
"""
144+
end
145+
146+
defp map_claims(claims, indent \\ "") do
147+
claims
148+
|> Enum.sort_by(&elem(&1, 0))
149+
|> Enum.reduce([], fn
150+
{key, %{} = claims}, acc ->
151+
acc ++ ["#{indent}- #{key}" | map_claims(claims, indent <> " ")]
152+
153+
{key, {type, _value}}, acc ->
154+
acc ++ ["#{indent}- `#{key}` as #{type}"]
155+
end)
156+
end
157+
end
158+
135159
@doc """
136160
Fetches the key value from the configuration.
137161

lib/assent/strategy.ex

+104-10
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ defmodule Assent.Strategy do
2626
end
2727
end
2828
"""
29+
alias Assent.CastClaimsError
30+
2931
@callback authorize_url(Keyword.t()) ::
3032
{:ok, %{:url => binary(), optional(atom()) => any()}} | {:error, term()}
3133
@callback callback(Keyword.t(), map()) ::
@@ -124,25 +126,117 @@ defmodule Assent.Strategy do
124126

125127
defp encode_value(value), do: URI.encode_www_form(Kernel.to_string(value))
126128

129+
@registered_claim_members %{
130+
"sub" => :binary,
131+
"name" => :binary,
132+
"given_name" => :binary,
133+
"family_name" => :binary,
134+
"middle_name" => :binary,
135+
"nickname" => :binary,
136+
"preferred_username" => :binary,
137+
"profile" => :binary,
138+
"picture" => :binary,
139+
"website" => :binary,
140+
"email" => :binary,
141+
"email_verified" => :boolean,
142+
"gender" => :binary,
143+
"birthdate" => :binary,
144+
"zoneinfo" => :binary,
145+
"locale" => :binary,
146+
"phone_number" => :binary,
147+
"phone_number_verified" => :boolean,
148+
"address" => %{
149+
"formatted" => :binary,
150+
"street_address" => :binary,
151+
"locality" => :binary,
152+
"region" => :binary,
153+
"postal_code" => :binary,
154+
"country" => :binary
155+
},
156+
"updated_at" => :integer
157+
}
158+
127159
@doc """
128160
Normalize API user request response into standard claims.
129161
162+
The function will cast values to adhere to the following types:
163+
164+
```
165+
#{inspect(@registered_claim_members, pretty: true)}
166+
```
167+
168+
Returns a `Assent.CastClaimsError` if any of the above types can't be casted.
169+
130170
Based on https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.1
131171
"""
132-
@spec normalize_userinfo(map(), map()) :: {:ok, map()}
172+
@spec normalize_userinfo(map(), map()) :: {:ok, map()} | {:error, term()}
133173
def normalize_userinfo(claims, extra \\ %{}) do
134-
standard_claims =
135-
Map.take(
136-
claims,
137-
~w(sub name given_name family_name middle_name nickname
138-
preferred_username profile picture website email email_verified
139-
gender birthdate zoneinfo locale phone_number phone_number_verified
140-
address updated_at)
141-
)
174+
case traverse_claims(@registered_claim_members, claims) do
175+
{claims, nil} -> {:ok, Map.merge(prune(extra), claims || %{})}
176+
{_claims, invalid_claims} -> {:error, CastClaimsError.exception(claims: invalid_claims)}
177+
end
178+
end
179+
180+
defp traverse_claims(claim_members, claims) do
181+
claim_members
182+
|> Enum.reduce({[], []}, &traverse_claims(&1, &2, Map.get(claims, elem(&1, 0))))
183+
|> then(fn {claims, invalid_claims} ->
184+
{
185+
(claims != [] && Enum.into(claims, %{})) || nil,
186+
(invalid_claims != [] && Enum.into(invalid_claims, %{})) || nil
187+
}
188+
end)
189+
end
190+
191+
defp traverse_claims(_, {valid_claims, invalid_claims}, nil), do: {valid_claims, invalid_claims}
192+
193+
defp traverse_claims(
194+
{key, %{} = claim_members},
195+
{parent_valid_claims, parent_invalid_claims},
196+
%{} = claims
197+
) do
198+
case traverse_claims(claim_members, claims) do
199+
{nil, nil} ->
200+
{parent_valid_claims, parent_invalid_claims}
201+
202+
{claims, nil} ->
203+
{[{key, claims} | parent_valid_claims], parent_invalid_claims}
204+
205+
{nil, invalid_claims} ->
206+
{parent_valid_claims, [{key, invalid_claims} | parent_invalid_claims]}
142207

143-
{:ok, prune(Map.merge(extra, standard_claims))}
208+
{claims, invalid_claims} ->
209+
{[{key, claims} | parent_valid_claims], [{key, invalid_claims} | parent_invalid_claims]}
210+
end
211+
end
212+
213+
defp traverse_claims({key, %{}}, {valid_claims, invalid_claims}, claims) do
214+
{valid_claims, [{key, {:map, claims}} | invalid_claims]}
144215
end
145216

217+
defp traverse_claims({key, type}, {valid_claims, invalid_claims}, value) do
218+
case cast_value(value, type) do
219+
{:ok, value} -> {[{key, value} | valid_claims], invalid_claims}
220+
:error -> {valid_claims, [{key, {type, value}} | invalid_claims]}
221+
end
222+
end
223+
224+
defp cast_value(value, :binary) when is_binary(value), do: {:ok, value}
225+
defp cast_value(value, :binary) when is_integer(value), do: {:ok, to_string(value)}
226+
227+
defp cast_value(value, :integer) when is_integer(value), do: {:ok, value}
228+
229+
defp cast_value(value, :integer) when is_binary(value) do
230+
case Integer.parse(value) do
231+
{integer, ""} -> {:ok, integer}
232+
_ -> :error
233+
end
234+
end
235+
236+
defp cast_value(value, :boolean) when is_boolean(value), do: {:ok, value}
237+
238+
defp cast_value(_value, _type), do: :error
239+
146240
@doc """
147241
Recursively prunes map for nil values.
148242
"""

test/assent/strategies/auth0_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ defmodule Assent.Strategy.Auth0Test do
2828
},
2929
"updated_at" => "1556845729"
3030
}
31-
@user @user_response
31+
@user %{@user_response | "updated_at" => 1_556_845_729}
3232

3333
describe "authorize_url/2" do
3434
test "requires domain or base_url configuration", %{config: config} do

test/assent/strategies/basecamp_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ defmodule Assent.Strategy.BasecampTest do
4242
"family_name" => "Fried",
4343
"given_name" => "Jason",
4444
"name" => "Jason Fried",
45-
"sub" => 9_999_999
45+
"sub" => "9999999"
4646
}
4747

4848
test "authorize_url/2", %{config: config} do

test/assent/strategies/github_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ defmodule Assent.Strategy.GithubTest do
6565
"picture" => "https://github.com/images/error/octocat_happy.gif",
6666
"preferred_username" => "octocat",
6767
"profile" => "https://github.com/octocat",
68-
"sub" => 1
68+
"sub" => "1"
6969
}
7070

7171
test "authorize_url/2", %{config: config} do

test/assent/strategies/gitlab_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ defmodule Assent.Strategy.GitlabTest do
4545
"name" => "John Smith",
4646
"picture" => "http://localhost:3000/uploads/user/avatar/1/index.jpg",
4747
"preferred_username" => "john_smith",
48-
"sub" => 1
48+
"sub" => "1"
4949
}
5050

5151
test "authorize_url/2", %{config: config} do

test/assent/strategies/strava_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ defmodule Assent.Strategy.StravaTest do
5353
}
5454

5555
@user %{
56-
"sub" => 1_234_567_890_987_654_321,
56+
"sub" => "1234567890987654321",
5757
"given_name" => "Marianne",
5858
"family_name" => "Teutenberg",
5959
"preferred_username" => "marianne_t",

test/assent/strategies/telegram_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule Assent.Strategy.TelegramTest do
1111
"username" => "duroff"
1212
}
1313
@user %{
14-
"sub" => 928_474_348,
14+
"sub" => "928474348",
1515
"family_name" => "Duroff",
1616
"given_name" => "Paul",
1717
"preferred_username" => "duroff",

test/assent/strategies/twitter_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ defmodule Assent.Strategy.TwitterTest do
124124
"picture" => "https://pbs.twimg.com/profile_images/880136122604507136/xHrnqf1T_normal.jpg",
125125
"preferred_username" => "TwitterDev",
126126
"profile" => "https://twitter.com/TwitterDev",
127-
"sub" => 2_244_994_945,
127+
"sub" => "2244994945",
128128
"website" => "https://t.co/FGl7VOULyL"
129129
}
130130

test/assent/strategies/vk_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ defmodule Assent.Strategy.VKTest do
1919
@user %{
2020
"given_name" => "Lindsay",
2121
"family_name" => "Stirling",
22-
"sub" => 210_700_286,
22+
"sub" => "210700286",
2323
"email" => "[email protected]"
2424
}
2525

test/assent/strategy_test.exs

+88-6
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,94 @@ defmodule Assent.StrategyTest do
6363
"http://example.com/path?a=1&b[c]=2&b[d][e]=3&f[]=4&f[]=5"
6464
end
6565

66-
test "normalize_userinfo/2" do
67-
user = %{"email" => "[email protected]", "name" => nil, "nickname" => "foo"}
68-
extra = %{"a" => "1"}
69-
expected = %{"email" => "[email protected]", "nickname" => "foo", "a" => "1"}
70-
71-
assert Strategy.normalize_userinfo(user, extra) == {:ok, expected}
66+
describe "normalize_userinfo/2" do
67+
@user %{
68+
"sub" => "123",
69+
"email" => "[email protected]",
70+
"email_verified" => true,
71+
"address" => %{
72+
"formatted" => "456"
73+
},
74+
"updated_at" => 1_516_239_022
75+
}
76+
77+
@expected %{
78+
"sub" => "123",
79+
"email" => "[email protected]",
80+
"email_verified" => true,
81+
"address" => %{
82+
"formatted" => "456"
83+
},
84+
"updated_at" => 1_516_239_022
85+
}
86+
87+
test "with invalid types" do
88+
user = %{
89+
@user
90+
| "sub" => true,
91+
"email_verified" => "false",
92+
"updated_at" => "invalid",
93+
"address" => "invalid"
94+
}
95+
96+
assert {:error, %Assent.CastClaimsError{} = error} = Strategy.normalize_userinfo(user, %{})
97+
98+
assert Exception.message(error) == """
99+
The following claims couldn't be cast:
100+
101+
- `address` as map
102+
- `email_verified` as boolean
103+
- `sub` as binary
104+
- `updated_at` as integer
105+
"""
106+
end
107+
108+
test "with invalid nested types" do
109+
user = %{@user | "address" => %{"formatted" => true}}
110+
111+
assert {:error, %Assent.CastClaimsError{} = error} = Strategy.normalize_userinfo(user, %{})
112+
113+
assert Exception.message(error) == """
114+
The following claims couldn't be cast:
115+
116+
- address
117+
- `formatted` as binary
118+
"""
119+
end
120+
121+
test "casts" do
122+
assert Strategy.normalize_userinfo(@user) == {:ok, @expected}
123+
end
124+
125+
test "casts with extra" do
126+
extra = %{"a" => 1}
127+
128+
assert Strategy.normalize_userinfo(@user, extra) == {:ok, Map.merge(@expected, extra)}
129+
end
130+
131+
test "extra with registered claims" do
132+
extra = %{"sub" => "different-sub"}
133+
134+
assert Strategy.normalize_userinfo(@user, extra) == {:ok, @expected}
135+
end
136+
137+
test "casts types" do
138+
user = %{
139+
@user
140+
| "sub" => 123,
141+
"address" => %{"formatted" => 456},
142+
"updated_at" => "1516239022"
143+
}
144+
145+
assert Strategy.normalize_userinfo(user) == {:ok, @expected}
146+
end
147+
148+
test "with unknown claims" do
149+
user =
150+
Map.merge(@user, %{"foo" => "bar", "address" => %{"foo" => "bar", "formatted" => "456"}})
151+
152+
assert Strategy.normalize_userinfo(user) == {:ok, @expected}
153+
end
72154
end
73155

74156
test "prune/1" do

0 commit comments

Comments
 (0)