Skip to content

Commit 20344f4

Browse files
committed
feat(backend): introduce OpenID Connect fields to Actor
Add the OpenID connect fields of interest for Edgehog to the `Edgehog.Actors.Actor` resource, and update the `gen-edgehog-jwt` script to support the creation of JWTs with the new fields. Signed-off-by: Damiano Mason <damiano.mason@secomind.com>
1 parent fad68a6 commit 20344f4

4 files changed

Lines changed: 214 additions & 18 deletions

File tree

backend/lib/edgehog/actors/actor.ex

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ defmodule Edgehog.Actors.Actor do
2222
@moduledoc """
2323
Edgheog Actors.
2424
This module represents an actor performing a call trough the GraphQL APIs.
25+
It implements the ID Token from the OpenID Connect spec.
2526
"""
2627

2728
use Ash.Resource,
@@ -36,11 +37,85 @@ defmodule Edgehog.Actors.Actor do
3637
defaults [:read]
3738

3839
create :from_claims do
39-
accept [:claims]
40+
accept [
41+
:sub,
42+
:aud,
43+
:exp,
44+
:iat,
45+
:auth_time,
46+
:preferred_username,
47+
:email,
48+
:given_name,
49+
:family_name,
50+
:claims
51+
]
4052
end
4153
end
4254

4355
attributes do
44-
attribute :claims, :map, allow_nil?: false
56+
# TODO: consider using issuer claim
57+
# Not required in our use-case for now
58+
59+
attribute :sub, :string do
60+
description """
61+
Subject Identifier, a unique and never reassigned identifier within the Issuer for the user.
62+
Only at most 255 ASCII characters are allowed. For example, the user's Keycloak UUID
63+
"""
64+
end
65+
66+
attribute :aud, :string do
67+
description """
68+
The OAuth2 `client_id` of the of the Client requesting auth on behalf of the user
69+
"""
70+
end
71+
72+
attribute :exp, :datetime do
73+
description """
74+
Expiration time of the token (JWT) corresponding to the current actor
75+
"""
76+
end
77+
78+
attribute :iat, :datetime do
79+
description """
80+
Time at which the token (JWT) corresponding to the current actor was issued at
81+
"""
82+
end
83+
84+
attribute :auth_time, :datetime do
85+
description """
86+
Time at which the user authentication occurred
87+
"""
88+
end
89+
90+
attribute :preferred_username, :string do
91+
description """
92+
Username of the user represented by the current actor
93+
"""
94+
end
95+
96+
attribute :email, :string do
97+
description """
98+
Email connected to the user's ID
99+
"""
100+
end
101+
102+
attribute :given_name, :string do
103+
description """
104+
Given name of the user
105+
"""
106+
end
107+
108+
attribute :family_name, :string do
109+
description """
110+
Family name of the user
111+
"""
112+
end
113+
114+
attribute :claims, :map do
115+
description """
116+
DEPRECATED: claims specified as a map are deprecated, and will be dropped once
117+
the new authentication/authorization flow is finalized
118+
"""
119+
end
45120
end
46121
end

backend/lib/edgehog_web/auth/token.ex

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,51 @@ defmodule EdgehogWeb.Auth.Token do
3838
# TODO: for now we just check that some e_tga claims are encoded in the token,
3939
# and we don't care about their value
4040
# e_tga = Edgehog Tenant GraphQL API
41+
# The same is true for the OpenID connect claims required by Edgehog
42+
# but only e_tga is used for validating auth for now
4143
case Map.fetch(claims, "e_tga") do
42-
{:ok, claims} ->
44+
{:ok, e_tga_value} ->
45+
claims = oidc_claims(claims)
46+
4347
Edgehog.Actors.Actor
44-
|> Ash.Changeset.for_create(:from_claims, %{claims: %{e_tga: claims}})
48+
|> Ash.Changeset.for_create(
49+
:from_claims,
50+
Map.put(claims, :claims, %{e_tga: e_tga_value})
51+
)
4552
|> Ash.create()
4653

4754
:error ->
4855
{:error, :no_valid_claims}
4956
end
5057
end
58+
59+
defp oidc_claims(submitted_claims) when is_map(submitted_claims) do
60+
required_claims = [
61+
:sub,
62+
:aud,
63+
:exp,
64+
:iat,
65+
:auth_time,
66+
:preferred_username,
67+
:email,
68+
:given_name,
69+
:family_name
70+
]
71+
72+
Enum.reduce(required_claims, %{}, fn claim_name, acc ->
73+
case Map.fetch(submitted_claims, Atom.to_string(claim_name)) do
74+
{:ok, value} -> Map.put(acc, claim_name, timestamp_to_datetime(value))
75+
_ -> acc
76+
end
77+
end)
78+
end
79+
80+
defp timestamp_to_datetime(timestamp_or_else) when is_integer(timestamp_or_else) do
81+
case DateTime.from_unix(timestamp_or_else) do
82+
{:ok, dt} -> dt
83+
{:error, _} -> nil
84+
end
85+
end
86+
87+
defp timestamp_to_datetime(timestamp_or_else), do: timestamp_or_else
5188
end

backend/test/support/tenant_api/conn_case.ex

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,23 @@ defmodule EdgehogWeb.ConnCase do
9191
|> X509.PrivateKey.to_pem()
9292
|> JOSE.JWK.from_pem()
9393

94+
now = DateTime.utc_now()
95+
96+
base_claims = %{
97+
iss: "https://example.issuer.com/current",
98+
sub: Ash.UUIDv7.generate(),
99+
aud: "edgehog-test",
100+
exp: now |> DateTime.shift(hour: 2, day: 1) |> DateTime.to_unix(),
101+
iat: DateTime.to_unix(now),
102+
auth_time: now |> DateTime.shift(hour: 1) |> DateTime.to_unix(),
103+
preferred_username: "username",
104+
email: "example@email.com",
105+
given_name: "John",
106+
family_name: "Doe"
107+
}
108+
94109
# The value of e_tga claims is ignored for now
95-
claims = claims || %{e_tga: true}
110+
claims = Map.merge(base_claims, claims || %{e_tga: true})
96111

97112
# Generate the JWT
98113
{:ok, jwt, _claims} =

tools/gen-edgehog-jwt

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ defmodule GenEdgehogJwt do
6464
%{}
6565
|> add_type_claim(opts[:token_type])
6666
|> add_expiry_claim(opts[:expiry])
67+
|> add_auth_claims(opts)
6768

6869
case Joken.generate_and_sign(%{}, claims, signer) do
6970
{:ok, token, _claims} ->
@@ -78,14 +79,34 @@ defmodule GenEdgehogJwt do
7879
defp parse_args(args) do
7980
args
8081
|> OptionParser.parse!(
81-
switches: [private_key: :string, expiry: :integer, token_type: :string],
82-
aliases: [k: :private_key, e: :expiry, t: :token_type]
82+
switches: [
83+
private_key: :string,
84+
expiry: :integer,
85+
token_type: :string,
86+
subject: :string,
87+
audience: :string,
88+
preferred_username: :string,
89+
email: :string,
90+
given_name: :string,
91+
family_name: :string
92+
],
93+
aliases: [
94+
k: :private_key,
95+
e: :expiry,
96+
t: :token_type,
97+
s: :subject,
98+
a: :audience,
99+
u: :preferred_username,
100+
m: :email,
101+
n: :given_name,
102+
N: :family_name
103+
]
83104
)
84105
|> validate_args()
85106
rescue
86107
e in OptionParser.ParseError ->
87108
print_usage()
88-
IO.puts(:stderr,"#{@red}gen-edgehog-jwt: error: #{e.message}#{@reset}")
109+
IO.puts(:stderr, "#{@red}gen-edgehog-jwt: error: #{e.message}#{@reset}")
89110
System.halt(1)
90111
end
91112

@@ -94,28 +115,39 @@ defmodule GenEdgehogJwt do
94115
!opts[:private_key] ->
95116
print_usage()
96117

97-
IO.puts(:stderr, "#{@red}gen-edgehog-jwt: error: the following arguments are required: -k/--private-key#{@reset}")
118+
IO.puts(
119+
:stderr,
120+
"#{@red}gen-edgehog-jwt: error: the following arguments are required: -k/--private-key#{@reset}"
121+
)
98122

99123
System.halt(1)
100124

101125
!opts[:token_type] ->
102126
print_usage()
103127

104-
IO.puts(:stderr, "#{@red}gen-edgehog-jwt: error: the following arguments are required: -t/--token-type#{@reset}")
128+
IO.puts(
129+
:stderr,
130+
"#{@red}gen-edgehog-jwt: error: the following arguments are required: -t/--token-type#{@reset}"
131+
)
105132

106133
System.halt(1)
107134

108135
opts[:token_type] not in ["tenant", "admin"] ->
109136
print_usage()
110137

111-
IO.puts(:stderr,
138+
IO.puts(
139+
:stderr,
112140
"#{@red}gen-edgehog-jwt: error: invalid token type '#{opts[:token_type]}'. Choose 'tenant' or 'admin'.#{@reset}"
113141
)
114142

115143
System.halt(1)
116144

117145
true ->
118-
opts = Keyword.put_new(opts, :expiry, 86_400)
146+
opts =
147+
opts
148+
|> Keyword.put_new(:expiry, 86_400)
149+
|> Keyword.put(:issued_at, DateTime.to_unix(DateTime.utc_now()))
150+
119151
{opts, rest}
120152
end
121153
end
@@ -138,8 +170,35 @@ defmodule GenEdgehogJwt do
138170
Map.put(claims, "exp", exp)
139171
end
140172

173+
defp add_auth_claims(claims, opts) do
174+
subject_claim = opts[:subject]
175+
audience_claim = opts[:audience]
176+
username_claim = opts[:preferred_username]
177+
email_claim = opts[:email]
178+
given_name_claim = opts[:given_name]
179+
family_name_claim = opts[:family_name]
180+
181+
auth_time_claim =
182+
opts[:issued_at]
183+
|> DateTime.from_unix()
184+
|> elem(1)
185+
|> DateTime.add(1, :second)
186+
|> DateTime.to_unix()
187+
188+
claims
189+
|> Map.put("sub", subject_claim)
190+
|> Map.put("aud", audience_claim)
191+
|> Map.put("preferred_username", username_claim)
192+
|> Map.put("email", email_claim)
193+
|> Map.put("given_name", given_name_claim)
194+
|> Map.put("family_name", family_name_claim)
195+
|> Map.put("auth_time", auth_time_claim)
196+
end
197+
141198
defp print_usage do
142-
IO.puts("usage: gen-edgehog-jwt [-h] -k PRIVATE_KEY [-e EXPIRY] -t {tenant,admin}")
199+
IO.puts(
200+
"usage: gen-edgehog-jwt [-h] -k PRIVATE_KEY [-e EXPIRY] -t {tenant,admin} -s SUBJECT -a AUDIENCE -u PREFERRED_USERNAME -m EMAIL_ADDRESS -n GIVEN_NAME -N FAMILY_NAME"
201+
)
143202
end
144203

145204
defp print_help do
@@ -152,22 +211,32 @@ defmodule GenEdgehogJwt do
152211
Supports both #{@green}tenant#{@reset} and #{@green}admin#{@reset} tokens.
153212
154213
#{@yellow}Required Options:#{@reset}
155-
#{@green}-k, --private-key PATH#{@reset} Path to your private key file (RSA or EC)
156-
#{@green}-t, --token-type TYPE#{@reset} Token type: #{@green}tenant#{@reset} or #{@green}admin#{@reset}
214+
#{@green}-k, --private-key PATH#{@reset} Path to your private key file (RSA or EC)
215+
#{@green}-t, --token-type TYPE#{@reset} Token type: #{@green}tenant#{@reset} or #{@green}admin#{@reset}
157216
158217
#{@yellow}Optional Options:#{@reset}
159218
#{@green}-e, --expiry SECONDS#{@reset} Token expiration time in seconds (default: 86400); set to #{@green}0#{@reset} for no expiration
160219
#{@green}-h, --help#{@reset} Display this help message
220+
#{@green}-s, --subject SUBJECT#{@reset} Subject Identifier, a unique and never reassigned identifier. Max 255 ASCII characters
221+
#{@green}-a, --audience AUDIENCE#{@reset} The OAuth2 client_id of the of the Client requesting auth on behalf of the user
222+
#{@green}-u, --preferred-username USERNAME#{@reset} Username of the user
223+
#{@green}-m, --email EMAIL#{@reset} Email address of the user
224+
#{@green}-n, --given-name GIVEN_NAME#{@reset} Given name of the user
225+
#{@green}-N, --family-name FAMILY_NAME#{@reset} Family name of the user
226+
227+
#{@blue}AUTH_OPTIONS#{@reset}
228+
Options used for authentication and authorization of the user's client:
229+
#{@green}--subject#{@reset}, #{@green}--audience#{@reset}, #{@green}--preferred-username#{@reset}, #{@green}--email#{@reset}, #{@green}--given-name#{@reset}, #{@green}--family-name#{@reset}
161230
162231
#{@yellow}Examples:#{@reset}
163232
#{@blue}# Generate a tenant token with default expiry (24 hours)#{@reset}
164-
./gen-edgehog-jwt -k private_key.pem -t tenant
233+
./gen-edgehog-jwt -k private_key.pem -t tenant [AUTH_OPTIONS]
165234
166235
#{@blue}# Generate an admin token with 1 hour expiry#{@reset}
167-
./gen-edgehog-jwt -k private_key.pem -t admin -e 3600
236+
./gen-edgehog-jwt -k private_key.pem -t admin -e 3600 [AUTH_OPTIONS]
168237
169238
#{@blue}# Generate a token with no expiration#{@reset}
170-
./gen-edgehog-jwt -k private_key.pem -t tenant -e 0
239+
./gen-edgehog-jwt -k private_key.pem -t tenant -e 0 [AUTH_OPTIONS]
171240
""")
172241
end
173242
end

0 commit comments

Comments
 (0)