Skip to content

Commit

Permalink
fix(JWT): Generate tenant claims and validate them.
Browse files Browse the repository at this point in the history
  • Loading branch information
jimsynz committed Feb 18, 2025
1 parent dfc84fb commit 686f599
Show file tree
Hide file tree
Showing 9 changed files with 346 additions and 5 deletions.
52 changes: 51 additions & 1 deletion lib/ash_authentication/jwt/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule AshAuthentication.Jwt.Config do
revocation.
"""

alias Ash.Resource
alias Ash.{Error.Invalid.TenantRequired, Resource}
alias AshAuthentication.{Info, TokenResource}
alias Joken.{Config, Signer}

Expand Down Expand Up @@ -48,6 +48,11 @@ defmodule AshAuthentication.Jwt.Config do
&Joken.generate_jti/0,
&validate_jti(&1, &2, &3, opts)
)
|> Config.add_claim(
"tenant",
fn -> generate_tenant(resource, opts) end,
&validate_tenant(&1, resource, opts)
)
end

@doc """
Expand Down Expand Up @@ -77,6 +82,51 @@ defmodule AshAuthentication.Jwt.Config do
"~> #{vsn.major}.#{vsn.minor}"
end

@doc """
The generator function used to generate the "tenant" claim.
If the token resource or the user resource have tenancy enabled then we
validate the presence of the `tenant` option.
"""
@spec generate_tenant(Resource.t(), Keyword.t()) :: nil | String.t()
def generate_tenant(resource, options) do
if multitenancy_required?(resource) do
case Keyword.fetch(options, :tenant) do
{:ok, tenant} -> tenant
:error -> raise TenantRequired, resource: resource
end
end
end

@doc """
Validate that the "tenant" claim matches the provided tenant option.
If the token resource or the user resource have tenancy enabled then we
validate the value of the "token" claim.
"""
@spec validate_tenant(String.t(), Resource.t(), Keyword.t()) :: boolean()
def validate_tenant(tenant, resource, options) do
if multitenancy_required?(resource) do
case Keyword.fetch(options, :tenant) do
{:ok, ^tenant} -> true
_ -> false
end
else
true
end
end

defp multitenancy_required?(user_resource) do
if Resource.Info.multitenancy_strategy(user_resource) do
true
else
case Info.authentication_tokens_token_resource(user_resource) do
{:ok, token_resource} -> not is_nil(Resource.Info.multitenancy_strategy(token_resource))
_ -> false
end
end
end

@doc """
The validation function used to validate the "aud" claim.
Expand Down
42 changes: 42 additions & 0 deletions priv/repo/migrations/20250218022011_add_user_with_multitenancy.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Example.Repo.Migrations.AddUserWithMultitenancy do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""

use Ecto.Migration

def up do
create table(:user_with_multitenancy, primary_key: false) do
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
add(:email, :citext, null: false)
add(:hashed_password, :text)
add(:tenant, :text, null: false)

add(:created_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)

add(:updated_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
end

create unique_index(:user_with_multitenancy, [:tenant, :email],
name: "user_with_multitenancy_email_index"
)
end

def down do
drop_if_exists(
unique_index(:user_with_multitenancy, [:tenant, :email],
name: "user_with_multitenancy_email_index"
)
)

drop(table(:user_with_multitenancy))
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "tenant",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "created_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "A78FBE6B466C501B7ADFCA26D65967EBBBC9D5503BE161C21455EA3DFEADC486",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "user_with_multitenancy_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": "tenant",
"global": false,
"strategy": "attribute"
},
"repo": "Elixir.Example.Repo",
"schema": null,
"table": "user_with_multitenancy"
}
37 changes: 37 additions & 0 deletions test/ash_authentication/jwt/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,43 @@ defmodule AshAuthentication.Jwt.ConfigTest do
end
end

describe "generate_tenant/2" do
test "when multitenancy is enabled it returns the tenant option" do
assert "banana" = Config.generate_tenant(Example.UserWithMultitenancy, tenant: "banana")
end

test "when multitenancy is enabled and the tenant option is missing, it raises" do
assert_raise Ash.Error.Invalid.TenantRequired, fn ->
Config.generate_tenant(Example.UserWithMultitenancy, [])
end
end

test "when multitenancy is not enabled it returns nil" do
refute Config.generate_tenant(Example.UserWithTokenRequired, tenant: "banana")
end
end

describe "validate_tenant/2" do
test "when multitenancy is enabled and the tenant matches it validates" do
assert Config.validate_tenant("banana", Example.UserWithMultitenancy, tenant: "banana")
end

test "when multitenancy is enabled and the tenant doesn't match it doesn't validate" do
refute Config.validate_tenant("banana", Example.UserWithMultitenancy, tenant: "apple")
refute Config.validate_tenant(nil, Example.UserWithMultitenancy, tenant: "apple")
end

test "when multitenancy is enabled and no tenant option is present it doesn't validate" do
refute Config.validate_tenant("banana", Example.UserWithMultitenancy, [])
refute Config.validate_tenant(nil, Example.UserWithMultitenancy, [])
end

test "when multitenancy is not enabled it is always valid" do
assert Config.validate_tenant("banana", Example.UserWithTokenRequired, tenant: "apple")
assert Config.validate_tenant(nil, Example.UserWithTokenRequired, [])
end
end

describe "validate_jti/3" do
test "is true when the token has not been revoked" do
TokenResource
Expand Down
14 changes: 13 additions & 1 deletion test/ash_authentication/jwt_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ defmodule AshAuthentication.JwtTest do
end
end

describe "token_for_user/1" do
describe "token_for_user/3" do
test "correctly generates and signs tokens" do
user = build_user()
assert {:ok, token, claims} = Jwt.token_for_user(user)
Expand All @@ -45,6 +45,18 @@ defmodule AshAuthentication.JwtTest do
assert_in_delta(claims["nbf"], now, 1.5)
assert claims["sub"] == "user?id=#{user.id}"
end

test "it encodes the tenant when required" do
user = build_user_with_multitenancy(tenant: "banana")
assert {:ok, _token, claims} = Jwt.token_for_user(user, %{}, tenant: "banana")
assert claims["tenant"] == "banana"
end

test "it doesn't encode the tenant when multitenancy is disabled" do

Check failure on line 55 in test/ash_authentication/jwt_test.exs

View workflow job for this annotation

GitHub Actions / mix test

test token_for_user/3 it doesn't encode the tenant when multitenancy is disabled (AshAuthentication.JwtTest)
user = build_user()
assert {:ok, _token, claims} = Jwt.token_for_user(user, %{}, tenant: "banana")
refute is_map_key(claims, "tenant")
end
end

describe "verify/2" do
Expand Down
7 changes: 6 additions & 1 deletion test/ash_authentication_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ defmodule AshAuthenticationTest do

describe "authenticated_resources/0" do
test "it correctly locates all authenticatable resources" do
assert [Example.User, Example.UserWithTokenRequired, Example.UserWithRegisterMagicLink] =
assert [
Example.User,
Example.UserWithMultitenancy,
Example.UserWithRegisterMagicLink,
Example.UserWithTokenRequired
] =
authenticated_resources(:ash_authentication)
end
end
Expand Down
26 changes: 26 additions & 0 deletions test/support/data_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,30 @@ defmodule DataCase do
Ash.Resource.put_metadata(user, field, value)
end)
end

@doc "User with multitenancy enabled factory"
@spec build_user_with_multitenancy(keyword) ::
Example.UserWithMultitenancy.t() | no_return
def build_user_with_multitenancy(attrs \\ []) do
password = password()

attrs =
attrs
|> Map.new()
|> Map.put_new(:email, Faker.Internet.email())
|> Map.put_new(:password, password)
|> Map.put_new(:password_confirmation, password)
|> Map.put_new(:tenant, Faker.Lorem.word())

user =
Example.UserWithMultitenancy
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:register_with_password, attrs)
|> Ash.create!(tenant: attrs[:tenant])

attrs
|> Enum.reduce(user, fn {field, value}, user ->
Ash.Resource.put_metadata(user, field, value)
end)
end
end
5 changes: 3 additions & 2 deletions test/support/example.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ defmodule Example do
use Ash.Domain, otp_app: :ash_authentication, extensions: [AshGraphql.Domain, AshJsonApi.Domain]

resources do
resource Example.User
resource Example.UserWithTokenRequired
resource Example.Token
resource Example.User
resource Example.UserIdentity
resource Example.UserWithMultitenancy
resource Example.UserWithRegisterMagicLink
resource Example.UserWithTokenRequired
end

json_api do
Expand Down
Loading

0 comments on commit 686f599

Please sign in to comment.