Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/nova_auth.app.src
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
{vsn, "git"},
{registered, []},
{mod, {nova_auth_app, []}},
{applications, [kernel, stdlib, crypto, kura, nova, seki]},
{applications, [kernel, stdlib, crypto, nova, seki]},
{optional_applications, [kura]},
{env, []},
{modules, []},
{licenses, ["MIT"]},
Expand Down
18 changes: 15 additions & 3 deletions src/nova_auth.erl
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,29 @@
Behaviour for nova_auth configuration. Implementing modules define
authentication settings (repo, schemas, token lifetimes). Configuration
is cached in persistent_term for fast repeated access.

Password-related keys (`repo`, `user_schema`, `token_schema`) are only
required when using password authentication modules (nova_auth_accounts,
nova_auth_session, etc.). OIDC-only applications can omit them entirely.
""".

-include("../include/nova_auth.hrl").

-export([config/1, config/2, invalidate_cache/1]).

-export_type([actor/0]).

-type actor() :: #{
id := binary() | integer(),
provider := atom(),
atom() => term()
}.

-callback config() ->
#{
repo := module(),
user_schema := module(),
token_schema := module(),
repo => module(),
user_schema => module(),
token_schema => module(),
user_identity_field => atom(),
user_password_field => atom(),
session_validity_days => pos_integer(),
Expand Down
36 changes: 36 additions & 0 deletions src/nova_auth_actor.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-module(nova_auth_actor).
-moduledoc ~"""
Generic session actor storage. Stores and retrieves actor maps from
Nova's ETS session. Both password auth and OIDC write here, providing
a unified downstream experience for security callbacks and policies.
""".

-export([store/2, fetch/1, delete/1, session_key/0]).

-define(SESSION_KEY, ~"nova_auth_actor").

-doc "Store an actor map in the Nova session.".
-spec store(cowboy_req:req(), nova_auth:actor()) -> ok | {error, atom()}.
store(Req, Actor) when is_map(Actor) ->
nova_session:set(Req, ?SESSION_KEY, term_to_binary(Actor)).

-doc "Fetch the actor map from the Nova session.".
-spec fetch(cowboy_req:req()) -> {ok, nova_auth:actor()} | {error, not_found}.
fetch(Req) ->
case nova_session:get(Req, ?SESSION_KEY) of
{ok, Bin} when is_binary(Bin) ->
%% eqwalizer:fixme - binary_to_term returns term()
{ok, binary_to_term(Bin)};
_ ->
{error, not_found}
end.

-doc "Clear the actor from the Nova session.".
-spec delete(cowboy_req:req()) -> {ok, cowboy_req:req()} | {error, atom()}.
delete(Req) ->
nova_session:delete(Req, ?SESSION_KEY).

-doc "Return the session key used for actor storage.".
-spec session_key() -> binary().
session_key() ->
?SESSION_KEY.
52 changes: 52 additions & 0 deletions src/nova_auth_claims.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-module(nova_auth_claims).
-moduledoc ~"""
Claims mapping engine. Transforms provider-specific claims (e.g., OIDC
userinfo or JWT claims) into nova_auth actor maps. Supports static
key-renaming maps or callback functions for complex transformations.
""".

-export([map/2, map/3]).

-doc """
Map raw claims to an actor map using the given mapping spec.

Static map renames binary claim keys to atom keys:
```
Mapping = #{~"sub" => id, ~"email" => email, ~"groups" => roles},
Claims = #{~"sub" => ~"abc", ~"email" => ~"user@example.com"},
map(Mapping, Claims).
%% => #{id => ~"abc", email => ~"user@example.com"}
```

Callback form allows arbitrary transformation:
```
Mapping = {my_module, map_claims},
map(Mapping, Claims).
%% => my_module:map_claims(Claims)
```
""".
-spec map(Mapping, Claims) -> map() when
Mapping :: #{binary() => atom()} | {module(), atom()},
Claims :: map().
map({Mod, Fun}, Claims) when is_atom(Mod), is_atom(Fun) ->
Mod:Fun(Claims);
map(Mapping, Claims) when is_map(Mapping) ->
maps:fold(
fun(ClaimKey, ActorKey, Acc) ->
case maps:is_key(ClaimKey, Claims) of
true -> Acc#{ActorKey => maps:get(ClaimKey, Claims)};
false -> Acc
end
end,
#{},
Mapping
).

-doc "Map raw claims and merge into an existing actor map. New keys overwrite existing ones.".
-spec map(Mapping, Claims, Base) -> map() when
Mapping :: #{binary() => atom()} | {module(), atom()},
Claims :: map(),
Base :: map().
map(Mapping, Claims, Base) ->
Mapped = map(Mapping, Claims),
maps:merge(Base, Mapped).
29 changes: 24 additions & 5 deletions src/nova_auth_password.erl
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,22 @@
-moduledoc ~"""
Password hashing and verification using PBKDF2-SHA256. Includes constant-time
comparison and dummy verification to prevent user enumeration via timing attacks.

## Configuration

Set iterations via application environment:

```erlang
{nova_auth, [{pbkdf2_iterations, 600000}]}.
```

OWASP recommends 600,000 for PBKDF2-SHA256 (default). Lower values trade
security margin for speed — 100,000+ is reasonable for game backends.
""".

-export([hash/1, hash/2, verify/2, dummy_verify/0]).

-define(PBKDF2_ITERATIONS, 600000).
-define(DEFAULT_ITERATIONS, 600000).
-define(PBKDF2_LENGTH, 32).

-doc "Hash a password using the default algorithm (PBKDF2-SHA256).".
Expand All @@ -17,10 +28,11 @@ hash(Password) ->
-doc "Hash a password using the specified algorithm.".
-spec hash(binary(), pbkdf2_sha256 | bcrypt | argon2) -> binary().
hash(Password, pbkdf2_sha256) ->
Iterations = iterations(),
Salt = crypto:strong_rand_bytes(16),
DK = crypto:pbkdf2_hmac(sha256, Password, Salt, ?PBKDF2_ITERATIONS, ?PBKDF2_LENGTH),
Iterations = integer_to_binary(?PBKDF2_ITERATIONS),
<<"$pbkdf2-sha256$", Iterations/binary, "$", (base64:encode(Salt))/binary, "$",
DK = crypto:pbkdf2_hmac(sha256, Password, Salt, Iterations, ?PBKDF2_LENGTH),
IterBin = integer_to_binary(Iterations),
<<"$pbkdf2-sha256$", IterBin/binary, "$", (base64:encode(Salt))/binary, "$",
(base64:encode(DK))/binary>>;
hash(Password, bcrypt) ->
hash(Password, pbkdf2_sha256);
Expand Down Expand Up @@ -48,6 +60,13 @@ verify(_Password, _Hash) ->
-doc "Simulate password verification timing to prevent user enumeration.".
-spec dummy_verify() -> false.
dummy_verify() ->
Iterations = iterations(),
Salt = crypto:strong_rand_bytes(16),
_ = crypto:pbkdf2_hmac(sha256, <<"dummy">>, Salt, ?PBKDF2_ITERATIONS, ?PBKDF2_LENGTH),
_ = crypto:pbkdf2_hmac(sha256, <<"dummy">>, Salt, Iterations, ?PBKDF2_LENGTH),
false.

%% --- Internal ---

-spec iterations() -> pos_integer().
iterations() ->
application:get_env(nova_auth, pbkdf2_iterations, ?DEFAULT_ITERATIONS).
28 changes: 28 additions & 0 deletions src/nova_auth_policy.erl
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ condition functions that can be evaluated against an actor and context.
-export([
allow_authenticated/0,
allow_role/1,
allow_claim/2,
allow_owner/1,
deny_all/0
]).
Expand Down Expand Up @@ -34,6 +35,33 @@ allow_role(Roles) when is_list(Roles) ->
end
}.

-doc """
Allow actors who have a specific claim value. Works with both single-valued
and list-valued claims (e.g., Authentik groups mapped to roles).

```
allow_claim(roles, admin)
allow_claim(roles, [admin, editor])
```
""".
-spec allow_claim(atom(), term() | [term()]) -> policy().
allow_claim(ClaimKey, Value) when not is_list(Value) ->
allow_claim(ClaimKey, [Value]);
allow_claim(ClaimKey, Values) when is_list(Values) ->
#{
action => '_',
condition => fun(Actor, _Extra) ->
case maps:get(ClaimKey, Actor, undefined) of
undefined ->
false;
ActorValue when is_list(ActorValue) ->
lists:any(fun(V) -> lists:member(V, ActorValue) end, Values);
ActorValue ->
lists:member(ActorValue, Values)
end
end
}.

-doc "Allow actors who own the record (actor id matches the owner field).".
-spec allow_owner(atom()) -> policy().
allow_owner(OwnerField) ->
Expand Down
40 changes: 19 additions & 21 deletions src/nova_auth_security.erl
Original file line number Diff line number Diff line change
@@ -1,32 +1,30 @@
-module(nova_auth_security).
-moduledoc ~"""
Nova security callback for route-level authentication. Returns a closure
Nova security callback for route-level authentication. Returns closures
suitable for use in Nova route security configuration.

Uses the unified actor session (`nova_auth_actor`) so it works with
any auth strategy (password, OIDC, JWT) that stores an actor there.
""".

-export([require_authenticated/1, require_authenticated/2]).
-export([require_authenticated/0, require_authenticated/1]).

-doc "Return a security fun bound to the given auth module for use in route config.".
-spec require_authenticated(module()) -> fun((cowboy_req:req()) -> term()).
require_authenticated(AuthMod) ->
fun(Req) -> require_authenticated(AuthMod, Req) end.
-doc "Return a security fun that checks for any authenticated actor in the session.".
-spec require_authenticated() -> fun((cowboy_req:req()) -> term()).
require_authenticated() ->
fun require_authenticated/1.

-doc "Check the session for a valid token and return the user or 401.".
-spec require_authenticated(module(), cowboy_req:req()) ->
{true, map()} | {false, integer(), map(), binary()}.
require_authenticated(AuthMod, Req) ->
case nova_session:get(Req, <<"session_token">>) of
{ok, Token} ->
case nova_auth_session:get_user_by_session_token(AuthMod, Token) of
{ok, User} ->
{true, User};
_ ->
unauthorized()
end;
_ ->
-doc "Check the session for an authenticated actor and return it or 401.".
-spec require_authenticated(cowboy_req:req()) ->
{true, nova_auth:actor()} | {false, integer(), map(), binary()}.
require_authenticated(Req) ->
case nova_auth_actor:fetch(Req) of
{ok, Actor} ->
{true, Actor};
{error, not_found} ->
unauthorized()
end.

unauthorized() ->
Body = iolist_to_binary(json:encode(#{<<"error">> => <<"unauthorized">>})),
{false, 401, #{<<"content-type">> => <<"application/json">>}, Body}.
Body = iolist_to_binary(json:encode(#{~"error" => ~"unauthorized"})),
{false, 401, #{~"content-type" => ~"application/json"}, Body}.
73 changes: 73 additions & 0 deletions test/nova_auth_claims_SUITE.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
-module(nova_auth_claims_SUITE).
-behaviour(ct_suite).
-include_lib("stdlib/include/assert.hrl").

-export([all/0, groups/0]).
-export([
static_map_renames_keys/1,
static_map_skips_missing_claims/1,
static_map_empty/1,
callback_mapping/1,
map3_merges_with_base/1,
map3_overwrites_base/1
]).

%% Callback used by callback_mapping test
-export([test_mapping/1]).

all() ->
[{group, claims_tests}].

groups() ->
[
{claims_tests, [parallel], [
static_map_renames_keys,
static_map_skips_missing_claims,
static_map_empty,
callback_mapping,
map3_merges_with_base,
map3_overwrites_base
]}
].

static_map_renames_keys(_Config) ->
Mapping = #{~"sub" => id, ~"email" => email, ~"groups" => roles},
Claims = #{~"sub" => ~"abc123", ~"email" => ~"user@example.com", ~"groups" => [~"admins"]},
Result = nova_auth_claims:map(Mapping, Claims),
?assertEqual(~"abc123", maps:get(id, Result)),
?assertEqual(~"user@example.com", maps:get(email, Result)),
?assertEqual([~"admins"], maps:get(roles, Result)).

static_map_skips_missing_claims(_Config) ->
Mapping = #{~"sub" => id, ~"email" => email, ~"name" => display_name},
Claims = #{~"sub" => ~"abc123"},
Result = nova_auth_claims:map(Mapping, Claims),
?assertEqual(~"abc123", maps:get(id, Result)),
?assertNot(maps:is_key(email, Result)),
?assertNot(maps:is_key(display_name, Result)).

static_map_empty(_Config) ->
?assertEqual(#{}, nova_auth_claims:map(#{}, #{~"sub" => ~"abc"})).

callback_mapping(_Config) ->
Result = nova_auth_claims:map({?MODULE, test_mapping}, #{~"sub" => ~"42", ~"role" => ~"admin"}),
?assertEqual(#{id => ~"42", role => admin}, Result).

map3_merges_with_base(_Config) ->
Mapping = #{~"email" => email},
Claims = #{~"email" => ~"user@example.com"},
Base = #{id => ~"123", provider => authentik},
Result = nova_auth_claims:map(Mapping, Claims, Base),
?assertEqual(~"123", maps:get(id, Result)),
?assertEqual(authentik, maps:get(provider, Result)),
?assertEqual(~"user@example.com", maps:get(email, Result)).

map3_overwrites_base(_Config) ->
Mapping = #{~"email" => email},
Claims = #{~"email" => ~"new@example.com"},
Base = #{email => ~"old@example.com"},
Result = nova_auth_claims:map(Mapping, Claims, Base),
?assertEqual(~"new@example.com", maps:get(email, Result)).

test_mapping(#{~"sub" := Sub, ~"role" := Role}) ->
#{id => Sub, role => binary_to_atom(Role)}.
Loading
Loading