From 010e7ed290eeab8bc0cb53b4764bb58c5fbeca90 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 26 Mar 2026 10:10:40 +0000 Subject: [PATCH 1/8] feat: unified actor session and claims mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add unified actor session and claims mapping Add nova_auth_actor for strategy-agnostic session storage, nova_auth_claims for provider claim transformation, and allow_claim/2 policy. Make kura optional so OIDC-only apps don't need a database. Simplify security callbacks to use the shared actor session. * fix: remove unused NOVA_AUTH_ACTOR_SESSION_KEY macro Hank flagged it as dead code — the session key is defined locally in nova_auth_actor instead. --- src/nova_auth.app.src | 3 +- src/nova_auth.erl | 18 ++++++-- src/nova_auth_actor.erl | 36 ++++++++++++++++ src/nova_auth_claims.erl | 52 +++++++++++++++++++++++ src/nova_auth_policy.erl | 28 +++++++++++++ src/nova_auth_security.erl | 40 +++++++++--------- test/nova_auth_claims_SUITE.erl | 73 +++++++++++++++++++++++++++++++++ test/nova_auth_policy_SUITE.erl | 28 +++++++++++++ 8 files changed, 253 insertions(+), 25 deletions(-) create mode 100644 src/nova_auth_actor.erl create mode 100644 src/nova_auth_claims.erl create mode 100644 test/nova_auth_claims_SUITE.erl diff --git a/src/nova_auth.app.src b/src/nova_auth.app.src index 68dc82a..89d011f 100644 --- a/src/nova_auth.app.src +++ b/src/nova_auth.app.src @@ -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"]}, diff --git a/src/nova_auth.erl b/src/nova_auth.erl index a968fed..4b73258 100644 --- a/src/nova_auth.erl +++ b/src/nova_auth.erl @@ -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(), diff --git a/src/nova_auth_actor.erl b/src/nova_auth_actor.erl new file mode 100644 index 0000000..e5fda08 --- /dev/null +++ b/src/nova_auth_actor.erl @@ -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. diff --git a/src/nova_auth_claims.erl b/src/nova_auth_claims.erl new file mode 100644 index 0000000..4cf3a9e --- /dev/null +++ b/src/nova_auth_claims.erl @@ -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). diff --git a/src/nova_auth_policy.erl b/src/nova_auth_policy.erl index 3969c3d..c499d0b 100644 --- a/src/nova_auth_policy.erl +++ b/src/nova_auth_policy.erl @@ -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 ]). @@ -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) -> diff --git a/src/nova_auth_security.erl b/src/nova_auth_security.erl index 17c4966..eae33bf 100644 --- a/src/nova_auth_security.erl +++ b/src/nova_auth_security.erl @@ -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}. diff --git a/test/nova_auth_claims_SUITE.erl b/test/nova_auth_claims_SUITE.erl new file mode 100644 index 0000000..6207150 --- /dev/null +++ b/test/nova_auth_claims_SUITE.erl @@ -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)}. diff --git a/test/nova_auth_policy_SUITE.erl b/test/nova_auth_policy_SUITE.erl index d9d2de4..35e3639 100644 --- a/test/nova_auth_policy_SUITE.erl +++ b/test/nova_auth_policy_SUITE.erl @@ -9,6 +9,10 @@ allow_role_single/1, allow_role_list/1, allow_role_wrong_role/1, + allow_claim_single_value/1, + allow_claim_list_values/1, + allow_claim_actor_has_list/1, + allow_claim_missing_key/1, allow_owner_read_returns_filter/1, allow_owner_write_matches/1, allow_owner_write_no_match/1, @@ -26,6 +30,10 @@ groups() -> allow_role_single, allow_role_list, allow_role_wrong_role, + allow_claim_single_value, + allow_claim_list_values, + allow_claim_actor_has_list, + allow_claim_missing_key, allow_owner_read_returns_filter, allow_owner_write_matches, allow_owner_write_no_match, @@ -53,6 +61,26 @@ allow_role_wrong_role(_Config) -> #{condition := Cond} = nova_auth_policy:allow_role(admin), ?assertNot(Cond(#{id => 1, role => user}, #{})). +allow_claim_single_value(_Config) -> + #{condition := Cond} = nova_auth_policy:allow_claim(role, admin), + ?assert(Cond(#{id => 1, role => admin}, #{})), + ?assertNot(Cond(#{id => 1, role => user}, #{})). + +allow_claim_list_values(_Config) -> + #{condition := Cond} = nova_auth_policy:allow_claim(role, [admin, editor]), + ?assert(Cond(#{id => 1, role => admin}, #{})), + ?assert(Cond(#{id => 1, role => editor}, #{})), + ?assertNot(Cond(#{id => 1, role => viewer}, #{})). + +allow_claim_actor_has_list(_Config) -> + #{condition := Cond} = nova_auth_policy:allow_claim(roles, admin), + ?assert(Cond(#{id => 1, roles => [admin, user]}, #{})), + ?assertNot(Cond(#{id => 1, roles => [user, viewer]}, #{})). + +allow_claim_missing_key(_Config) -> + #{condition := Cond} = nova_auth_policy:allow_claim(roles, admin), + ?assertNot(Cond(#{id => 1}, #{})). + allow_owner_read_returns_filter(_Config) -> #{condition := Cond} = nova_auth_policy:allow_owner(user_id), Result = Cond(#{id => 42}, #{type => read}), From 12f1be442387fc8703898a18672fbad5b9bd0fbf Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 26 Mar 2026 18:51:37 +0000 Subject: [PATCH 2/8] docs: update README and guides for unified actor session - Update README to reflect optional kura, actor session, and link to nova_auth_oidc - Update getting-started guide with OIDC-only and password auth paths - Update configuration guide with optional password keys and actor type - Add actor-session guide explaining the unified session concept - Add claims-mapping guide with static and callback examples - Add policies guide covering allow_claim and OIDC integration - Update ex_doc config with new guides and Taure source URL --- README.md | 128 ++++++++++++++++++++++--------------- guides/actor-session.md | 110 ++++++++++++++++++++++++++++++++ guides/claims-mapping.md | 120 ++++++++++++++++++++++++++++++++++ guides/configuration.md | 26 +++++++- guides/getting-started.md | 131 +++++++++++++++++++++++--------------- guides/policies.md | 94 +++++++++++++++++++++++++++ rebar.config | 5 +- 7 files changed, 510 insertions(+), 104 deletions(-) create mode 100644 guides/actor-session.md create mode 100644 guides/claims-mapping.md create mode 100644 guides/policies.md diff --git a/README.md b/README.md index ba7bd32..b88d8fd 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,17 @@ Authentication library for the [Nova](https://github.com/novaframework/nova) ecosystem. -Session-based authentication with PBKDF2-SHA256 password hashing, token lifecycle management, rate limiting, and policy helpers — everything needed to add auth to a Nova application without duplicating logic across projects. +Provides a unified actor session, claims mapping, authorization policies, and optional password-based authentication. Works standalone or as the foundation for [nova_auth_oidc](https://github.com/Taure/nova_auth_oidc). ## Features -- **PBKDF2-SHA256 hashing** — Secure password hashing using OTP's `crypto` module with 600,000 iterations. No NIF dependencies. -- **Session tokens** — Generate, validate, and revoke database-backed session tokens via Kura. -- **Rate limiting** — Nova plugin with configurable sliding-window rate limiting (ETS-backed). -- **Email confirmation** — Token-based email confirmation flow. -- **Password reset** — Token-based password reset flow with configurable expiry. -- **Security callback** — Drop-in Nova security function for protecting route groups. -- **Policy helpers** — Composable authorization policies for nova_resource (role-based, ownership, authenticated). -- **Timing-safe** — Dummy verification on failed lookups to prevent user enumeration. +- **Unified actor session** -- Strategy-agnostic session storage. Password auth, OIDC, JWT -- all produce the same actor map in the session. +- **Claims mapping** -- Transform provider-specific claims to actor maps with static maps or callback functions. +- **Security callbacks** -- Drop-in Nova security function for protecting route groups. +- **Policy helpers** -- Composable authorization policies (role-based, claim-based, ownership, authenticated). +- **Rate limiting** -- Nova plugin with configurable sliding-window rate limiting via Seki. +- **Password auth** (optional, requires [Kura](https://github.com/Taure/kura)) -- PBKDF2-SHA256 hashing, session tokens, email confirmation, password reset. +- **Timing-safe** -- Dummy verification on failed lookups to prevent user enumeration. ## Quick Start @@ -21,14 +20,34 @@ Add `nova_auth` to your deps: ```erlang {deps, [ - {nova_auth, {git, "https://github.com/novaframework/nova_auth.git", {branch, "main"}}} + {nova_auth, {git, "https://github.com/Taure/nova_auth.git", {branch, "main"}}} ]}. ``` -Create a config module: +### OIDC-only (no database) + +If you only need actor sessions and policies (e.g., with [nova_auth_oidc](https://github.com/Taure/nova_auth_oidc)): + +```erlang +%% Protect routes -- works with any auth strategy that stores an actor +#{prefix => ~"/dashboard", + security => nova_auth_security:require_authenticated(), + routes => [ + {~"/profile", fun my_controller:profile/1, #{methods => [get]}} + ]} + +%% Access actor in controller +profile(#{auth_data := Actor} = _Req) -> + Email = maps:get(email, Actor, ~"unknown"), + {json, #{email => Email}}. +``` + +### Password auth (requires Kura) + +Create a config module implementing the `nova_auth` behaviour: ```erlang --module(my_auth_config). +-module(my_auth). -behaviour(nova_auth). -export([config/0]). @@ -40,42 +59,54 @@ config() -> }. ``` -Protect routes: - -```erlang -#{prefix => <<"/api">>, - security => nova_auth_security:require_authenticated(my_auth_config), - routes => [ - {<<"/me">>, fun my_user_controller:show/1, #{methods => [get]}} - ]} -``` - Register and authenticate: ```erlang %% Register {ok, User} = nova_auth_accounts:register( - my_auth_config, fun my_user:registration_changeset/2, Params + my_auth, fun my_user:registration_changeset/2, Params ). -%% Authenticate -{ok, User} = nova_auth_accounts:authenticate( - my_auth_config, <<"user@example.com">>, <<"password123456">> -). +%% Authenticate and store actor in session +{ok, User} = nova_auth_accounts:authenticate(my_auth, ~"user@example.com", ~"password123456"). +ok = nova_auth_actor:store(Req, #{id => maps:get(id, User), provider => password, email => maps:get(email, User)}). -%% Session token -{ok, Token} = nova_auth_session:generate_session_token(my_auth_config, User). +%% Session token (database-backed) +{ok, Token} = nova_auth_session:generate_session_token(my_auth, User). ``` +## Modules + +### Core (no dependencies beyond Nova) + +| Module | Description | +|--------|-------------| +| `nova_auth_actor` | Store/fetch actor maps from Nova session | +| `nova_auth_claims` | Transform provider claims to actor maps | +| `nova_auth_security` | Route-level security callbacks | +| `nova_auth_policy` | Authorization policies for nova_resource | +| `nova_auth_rate_limit` | Rate limiting Nova plugin | + +### Password auth (requires Kura) + +| Module | Description | +|--------|-------------| +| `nova_auth_accounts` | Registration, authentication, password/identity changes | +| `nova_auth_session` | Database-backed session token management | +| `nova_auth_password` | PBKDF2-SHA256 password hashing | +| `nova_auth_token` | Token generation and validation | +| `nova_auth_confirm` | Email confirmation flow | +| `nova_auth_reset` | Password reset flow | + ## Configuration -All options with defaults: +Password auth options (all optional with defaults): | Option | Default | Description | |--------|---------|-------------| -| `repo` | *required* | Kura repo module | -| `user_schema` | *required* | Kura user schema module | -| `token_schema` | *required* | Kura token schema module | +| `repo` | -- | Kura repo module (required for password auth) | +| `user_schema` | -- | Kura user schema module | +| `token_schema` | -- | Kura token schema module | | `user_identity_field` | `email` | Field used for login lookup | | `user_password_field` | `hashed_password` | Field storing the password hash | | `session_validity_days` | `14` | Days before session tokens expire | @@ -84,29 +115,26 @@ All options with defaults: | `hash_algorithm` | `pbkdf2_sha256` | Password hashing algorithm | | `token_bytes` | `32` | Random bytes for token generation | -## Rate Limiting +## Guides -Add as a Nova plugin to any route group: - -```erlang -#{prefix => <<"/api">>, - plugins => [ - {pre_request, nova_auth_rate_limit, #{ - max_requests => 10, - window_seconds => 60 - }} - ], - routes => [...]} -``` +- [Getting Started](guides/getting-started.md) -- Installation and first setup +- [Configuration](guides/configuration.md) -- Full configuration reference +- [Actor Session](guides/actor-session.md) -- How the unified actor session works +- [Claims Mapping](guides/claims-mapping.md) -- Transforming provider claims +- [Policies](guides/policies.md) -- Authorization with nova_resource +- [Rate Limiting](guides/rate-limiting.md) -- Protecting routes from abuse -## Scaffolding +## Related Libraries -Use `rebar3 nova gen_auth` to generate schemas, controllers, and a config module that delegates to nova_auth. +- [nova_auth_oidc](https://github.com/Taure/nova_auth_oidc) -- OIDC login, JWT bearer validation, token introspection, client credentials +- [Nova](https://github.com/novaframework/nova) -- Web framework +- [Kura](https://github.com/Taure/kura) -- Database layer (optional) +- [Seki](https://github.com/Taure/seki) -- Rate limiting ## Requirements -- Erlang/OTP 27+ -- PostgreSQL (via Kura + pgo) +- Erlang/OTP 28+ +- PostgreSQL via Kura + pgo (only for password auth) ## License diff --git a/guides/actor-session.md b/guides/actor-session.md new file mode 100644 index 0000000..3ba79ef --- /dev/null +++ b/guides/actor-session.md @@ -0,0 +1,110 @@ +# Actor Session + +The actor session is the central concept in nova_auth. Regardless of how a user +authenticates (password, OIDC, JWT, custom), the result is an **actor map** stored +in the Nova session. All downstream code -- security callbacks, policies, +controllers -- works with this unified actor. + +## How It Works + +``` +Password login ──┐ + │ +OIDC callback ───┼──▶ nova_auth_actor:store(Req, Actor) ──▶ Nova ETS Session + │ +Custom auth ───┘ + │ + ▼ + nova_auth_actor:fetch(Req) ──▶ {ok, Actor} + │ + ▼ + nova_auth_security:require_authenticated() + nova_auth_policy:allow_role(admin) + Controller: #{auth_data := Actor} +``` + +## Actor Shape + +An actor is a map with two required keys and any additional fields: + +```erlang +#{ + id => ~"user-123", %% required: unique identifier + provider => authentik, %% required: auth strategy + email => ~"user@example.com", + roles => [admin, editor], + display_name => ~"Jane Doe" +} +``` + +The `provider` field identifies how the user authenticated. Common values: +`password`, `authentik`, `google`, `github`, `keycloak`. + +## API + +### Store + +```erlang +ok = nova_auth_actor:store(Req, #{ + id => ~"abc123", + provider => password, + email => ~"user@example.com" +}). +``` + +### Fetch + +```erlang +case nova_auth_actor:fetch(Req) of + {ok, Actor} -> Actor; + {error, not_found} -> not_logged_in +end. +``` + +### Delete (logout) + +```erlang +{ok, _Req} = nova_auth_actor:delete(Req). +``` + +### Session Key + +The actor is stored under the key `<<"nova_auth_actor">>`. You can retrieve it +with `nova_auth_actor:session_key()` if you need to reference it directly. + +## Security Callbacks + +`nova_auth_security:require_authenticated/0` returns a closure that checks +for an actor in the session: + +```erlang +#{prefix => ~"/api", + security => nova_auth_security:require_authenticated(), + routes => [...]} +``` + +If authenticated, the actor is passed to the controller as `auth_data`: + +```erlang +my_handler(#{auth_data := #{id := Id, roles := Roles}} = _Req) -> + {json, #{id => Id, roles => Roles}}. +``` + +If not authenticated, a 401 JSON response is returned automatically. + +## Mixed Auth Strategies + +When using both password auth and OIDC, both strategies store actors in the +same session key. The security callback doesn't need to know which strategy +was used: + +```erlang +%% Password login stores actor +ok = nova_auth_actor:store(Req, #{id => UserId, provider => password, ...}). + +%% OIDC callback stores actor (done by nova_auth_oidc_controller) +ok = nova_auth_actor:store(Req, #{id => Sub, provider => authentik, ...}). + +%% Same security callback protects both +security => nova_auth_security:require_authenticated() +``` diff --git a/guides/claims-mapping.md b/guides/claims-mapping.md new file mode 100644 index 0000000..9db45b2 --- /dev/null +++ b/guides/claims-mapping.md @@ -0,0 +1,120 @@ +# Claims Mapping + +`nova_auth_claims` transforms provider-specific claims (OIDC userinfo, JWT +claims, SAML attributes) into actor maps that work with nova_auth's policies +and security callbacks. + +## Static Mapping + +A static mapping is a map from binary claim keys to atom actor keys: + +```erlang +Mapping = #{ + ~"sub" => id, + ~"email" => email, + ~"name" => display_name, + ~"groups" => roles +}, + +Claims = #{ + ~"sub" => ~"abc123", + ~"email" => ~"user@example.com", + ~"name" => ~"Jane Doe", + ~"groups" => [~"admins", ~"developers"] +}, + +Actor = nova_auth_claims:map(Mapping, Claims). +%% => #{id => ~"abc123", email => ~"user@example.com", +%% display_name => ~"Jane Doe", roles => [~"admins", ~"developers"]} +``` + +Missing claims are skipped (no error, no `undefined` values): + +```erlang +nova_auth_claims:map(#{~"sub" => id, ~"phone" => phone}, #{~"sub" => ~"123"}). +%% => #{id => ~"123"} +%% phone is not in the result because the claim was missing +``` + +## Callback Mapping + +For complex transformations, use a `{Module, Function}` tuple: + +```erlang +-module(my_claims). +-export([map_authentik/1]). + +map_authentik(Claims) -> + Groups = maps:get(~"groups", Claims, []), + Role = case lists:member(~"admins", Groups) of + true -> admin; + false -> user + end, + #{ + id => maps:get(~"sub", Claims), + email => maps:get(~"email", Claims, undefined), + role => Role, + groups => Groups + }. +``` + +Use it in your config: + +```erlang +%% In nova_auth_oidc config +claims_mapping => {my_claims, map_authentik} +``` + +## Merging with a Base Map + +`nova_auth_claims:map/3` merges mapped claims into an existing map: + +```erlang +Base = #{provider => authentik}, +Mapping = #{~"sub" => id, ~"email" => email}, +Claims = #{~"sub" => ~"abc123", ~"email" => ~"user@example.com"}, + +nova_auth_claims:map(Mapping, Claims, Base). +%% => #{provider => authentik, id => ~"abc123", email => ~"user@example.com"} +``` + +New keys overwrite existing ones in the base map. + +## Provider-Specific Claim Examples + +### Authentik + +```erlang +#{ + ~"sub" => id, + ~"email" => email, + ~"name" => display_name, + ~"preferred_username" => username, + ~"groups" => roles +} +``` + +### Google + +```erlang +#{ + ~"sub" => id, + ~"email" => email, + ~"name" => display_name, + ~"picture" => avatar_url +} +``` + +### Keycloak + +```erlang +#{ + ~"sub" => id, + ~"email" => email, + ~"preferred_username" => username, + ~"realm_access" => realm_access +} +``` + +For Keycloak's nested `realm_access.roles`, use a callback mapping to extract +the roles list. diff --git a/guides/configuration.md b/guides/configuration.md index b8a0907..114ba7e 100644 --- a/guides/configuration.md +++ b/guides/configuration.md @@ -4,7 +4,13 @@ All configuration is provided through the `config/0` callback in your module implementing `-behaviour(nova_auth)`. The returned map is merged with defaults and cached in `persistent_term` for fast access. -## Required Keys +## Password Auth Keys + +These keys are only required when using password authentication modules +(`nova_auth_accounts`, `nova_auth_session`, etc.). OIDC-only applications +can omit them entirely. + +### Required (for password auth) | Key | Type | Description | |-----|------|-------------| @@ -12,7 +18,7 @@ and cached in `persistent_term` for fast access. | `user_schema` | `module()` | Kura schema for the users table | | `token_schema` | `module()` | Kura schema for the user tokens table | -## Optional Keys +### Optional | Key | Type | Default | Description | |-----|------|---------|-------------| @@ -46,6 +52,22 @@ config() -> }. ``` +## Actor Type + +All authentication strategies produce an actor map stored in the Nova session. +The actor type is defined as: + +```erlang +-type actor() :: #{ + id := binary() | integer(), + provider := atom(), + atom() => term() +}. +``` + +The `id` and `provider` fields are required. Additional fields (email, roles, etc.) +depend on your authentication strategy and claims mapping. + ## Password Hashing The default algorithm is PBKDF2-SHA256 with these parameters: diff --git a/guides/getting-started.md b/guides/getting-started.md index 9f49f04..7794d38 100644 --- a/guides/getting-started.md +++ b/guides/getting-started.md @@ -6,14 +6,63 @@ Add `nova_auth` to your `rebar.config` dependencies: ```erlang {deps, [ - {nova_auth, {git, "https://github.com/novaframework/nova_auth.git", {branch, "main"}}} + {nova_auth, {git, "https://github.com/Taure/nova_auth.git", {branch, "main"}}} ]}. ``` -## Configuration Module +## Choose Your Strategy -Create a module implementing the `nova_auth` behaviour. This defines your repo, -schemas, and authentication settings: +nova_auth supports two usage patterns: + +1. **Core only** -- actor sessions, claims mapping, policies, security callbacks. No database required. +2. **Password auth** -- adds registration, login, session tokens, confirmation, reset. Requires [Kura](https://github.com/Taure/kura). + +For OIDC/OAuth2, see [nova_auth_oidc](https://github.com/Taure/nova_auth_oidc) which builds on nova_auth's actor session. + +## Core Only (No Database) + +### Protect routes + +Use `nova_auth_security:require_authenticated/0` to protect route groups: + +```erlang +#{prefix => ~"/dashboard", + security => nova_auth_security:require_authenticated(), + routes => [ + {~"/profile", fun my_controller:profile/1, #{methods => [get]}} + ]} +``` + +Any request without an actor in the session gets a 401 JSON response automatically. + +### Access the actor in controllers + +The actor is passed as `auth_data` in the request map: + +```erlang +profile(#{auth_data := Actor} = _Req) -> + #{id := Id, email := Email} = Actor, + {json, #{id => Id, email => Email}}. +``` + +### Store an actor manually + +If you have your own authentication logic, store the actor directly: + +```erlang +ok = nova_auth_actor:store(Req, #{ + id => ~"user-123", + provider => my_custom_auth, + email => ~"user@example.com", + roles => [admin] +}). +``` + +## Password Auth (Requires Kura) + +### Configuration module + +Create a module implementing the `nova_auth` behaviour: ```erlang -module(my_auth). @@ -30,7 +79,7 @@ config() -> All other options have sensible defaults (see the [Configuration](configuration.md) guide). -## User Schema +### User schema Define a Kura schema for your users table: @@ -41,7 +90,7 @@ Define a Kura schema for your users table: schema() -> #{ - source => <<"users">>, + source => ~"users", fields => #{ id => #{type => integer, primary_key => true}, email => #{type => string}, @@ -65,7 +114,7 @@ registration_changeset(Data, Params) -> end. ``` -## Token Schema +### Token schema Define a Kura schema for the user tokens table: @@ -76,7 +125,7 @@ Define a Kura schema for the user tokens table: schema() -> #{ - source => <<"user_tokens">>, + source => ~"user_tokens", fields => #{ id => #{type => integer, primary_key => true}, user_id => #{type => integer}, @@ -87,69 +136,49 @@ schema() -> }. ``` -## Route Protection - -Use `nova_auth_security:require_authenticated/1` in your Nova route groups to -protect endpoints: - -```erlang -%% In your Nova router -#{prefix => "/api", - security => nova_auth_security:require_authenticated(my_auth), - routes => [ - {"/profile", {my_profile_controller, handle}, #{methods => [get]}} - ]} -``` - -The security function checks the session for a valid token. If the user is -authenticated, the user map is passed as the security state. If not, a 401 -JSON response is returned automatically. - -## Registration - -Register a user with a changeset function: +### Registration ```erlang handle_register(Req) -> - {ok, Body, Req1} = cowboy_req:read_body(Req), + {ok, Body, _Req1} = cowboy_req:read_body(Req), Params = json:decode(Body), case nova_auth_accounts:register(my_auth, fun my_user:registration_changeset/2, Params) of {ok, User} -> - {json, 201, #{}, #{<<"id">> => maps:get(id, User)}}; + %% Store actor in session + ok = nova_auth_actor:store(Req, #{ + id => maps:get(id, User), + provider => password, + email => maps:get(email, User) + }), + {json, 201, #{}, #{~"id" => maps:get(id, User)}}; {error, Changeset} -> - {json, 422, #{}, #{<<"errors">> => kura_changeset:errors(Changeset)}} + {json, 422, #{}, #{~"errors" => kura_changeset:errors(Changeset)}} end. ``` -## Login - -Authenticate and create a session: +### Login ```erlang handle_login(Req) -> - {ok, Body, Req1} = cowboy_req:read_body(Req), - #{<<"email">> := Email, <<"password">> := Password} = json:decode(Body), + {ok, Body, _Req1} = cowboy_req:read_body(Req), + #{~"email" := Email, ~"password" := Password} = json:decode(Body), case nova_auth_accounts:authenticate(my_auth, Email, Password) of {ok, User} -> - {ok, Token} = nova_auth_session:generate_session_token(my_auth, User), - Req2 = nova_session:set(Req1, <<"session_token">>, Token), - {json, 200, #{}, #{<<"user_id">> => maps:get(id, User)}}; + ok = nova_auth_actor:store(Req, #{ + id => maps:get(id, User), + provider => password, + email => maps:get(email, User) + }), + {json, 200, #{}, #{~"user_id" => maps:get(id, User)}}; {error, invalid_credentials} -> - {json, 401, #{}, #{<<"error">> => <<"invalid credentials">>}} + {json, 401, #{}, #{~"error" => ~"invalid credentials"}} end. ``` -## Logout - -Delete the session token: +### Logout ```erlang handle_logout(Req) -> - case nova_session:get(Req, <<"session_token">>) of - {ok, Token} -> - nova_auth_session:delete_session_token(my_auth, Token), - {json, 200, #{}, #{<<"ok">> => true}}; - _ -> - {json, 200, #{}, #{<<"ok">> => true}} - end. + nova_auth_actor:delete(Req), + {json, 200, #{}, #{~"ok" => true}}. ``` diff --git a/guides/policies.md b/guides/policies.md new file mode 100644 index 0000000..7b2a875 --- /dev/null +++ b/guides/policies.md @@ -0,0 +1,94 @@ +# Policies + +`nova_auth_policy` provides composable authorization policies for use with +`nova_resource`. Each policy returns a map with an action and a condition +function that evaluates an actor. + +## Available Policies + +### allow_authenticated + +Allow any non-undefined actor: + +```erlang +nova_auth_policy:allow_authenticated() +``` + +### allow_role + +Allow actors whose `role` field matches: + +```erlang +nova_auth_policy:allow_role(admin) +nova_auth_policy:allow_role([admin, moderator]) +``` + +### allow_claim + +Allow actors with a specific claim value. Works with both single-valued and +list-valued claims: + +```erlang +%% Actor has role => admin +nova_auth_policy:allow_claim(role, admin) + +%% Actor has role in [admin, editor] +nova_auth_policy:allow_claim(role, [admin, editor]) + +%% Actor has roles => [admin, user] (list-valued claim) +nova_auth_policy:allow_claim(roles, admin) +%% Checks if admin is in the actor's roles list +``` + +This is useful with OIDC providers like Authentik that include group +memberships as list claims. + +### allow_owner + +Allow actors who own the record. For read operations, returns a query filter. +For write operations, checks the owner field: + +```erlang +nova_auth_policy:allow_owner(user_id) +``` + +### deny_all + +Deny unconditionally: + +```erlang +nova_auth_policy:deny_all() +``` + +## Usage with nova_resource + +```erlang +-module(my_resource). +-behaviour(nova_resource). + +policies() -> + [ + nova_auth_policy:allow_role(admin), + nova_auth_policy:allow_owner(user_id) + ]. +``` + +## Combining allow_claim with OIDC + +When using Authentik or similar providers with claims mapping: + +```erlang +%% In your OIDC config +claims_mapping => #{ + ~"sub" => id, + ~"email" => email, + ~"groups" => roles %% Authentik groups mapped to roles +} + +%% In your resource +policies() -> + [nova_auth_policy:allow_claim(roles, ~"admins")]. +``` + +The `allow_claim` policy checks if `~"admins"` is in the actor's `roles` +list, which was populated from Authentik's `groups` claim via the mapping. diff --git a/rebar.config b/rebar.config index 7912c1a..bdb4f99 100644 --- a/rebar.config +++ b/rebar.config @@ -44,8 +44,11 @@ {extras, [ <<"guides/getting-started.md">>, <<"guides/configuration.md">>, + <<"guides/actor-session.md">>, + <<"guides/claims-mapping.md">>, + <<"guides/policies.md">>, <<"guides/rate-limiting.md">> ]}, {main, <<"getting-started">>}, - {source_url, <<"https://github.com/novaframework/nova_auth">>} + {source_url, <<"https://github.com/Taure/nova_auth">>} ]}. From 5259d740d898b4074a472691c2f66cdb84150e6e Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 26 Mar 2026 19:28:51 +0000 Subject: [PATCH 3/8] chore: disable audit, upgrade erlang-ci to v2.0.9 (#4) * chore: disable audit, upgrade erlang-ci to v2.0.9, fix permissions * fix: add kura to plt_extra_apps for dialyzer --- .github/workflows/ci.yml | 12 ++++++------ rebar.config | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 229d490..54db2d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,22 +6,22 @@ on: pull_request: branches: [main] +permissions: + contents: write + pull-requests: write + jobs: ci: - uses: Taure/erlang-ci/.github/workflows/ci.yml@v1 - permissions: - contents: write - pull-requests: write + uses: Taure/erlang-ci/.github/workflows/ci.yml@v2.0.9 with: version-file: '.tool-versions' enable-ct: true enable-ex-doc: true enable-hank: true - enable-audit: true + enable-audit: false enable-coverage: true enable-sbom: true enable-sbom-scan: true enable-dependency-submission: true enable-summary: true - extra-services-compose: 'docker-compose.ci.yml' diff --git a/rebar.config b/rebar.config index bdb4f99..92dca1c 100644 --- a/rebar.config +++ b/rebar.config @@ -21,7 +21,8 @@ {dialyzer, [ {warnings, [error_handling, unmatched_returns, unknown]}, - {plt_apps, all_deps} + {plt_apps, all_deps}, + {plt_extra_apps, [kura]} ]}. {xref_checks, [undefined_function_calls, undefined_functions]}. From 9228cf177ec03fbb714b1e3c40e809ae920da473 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Sun, 29 Mar 2026 12:50:40 +0100 Subject: [PATCH 4/8] feat: make PBKDF2 iterations configurable (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: unified actor session and claims mapping * feat: add unified actor session and claims mapping Add nova_auth_actor for strategy-agnostic session storage, nova_auth_claims for provider claim transformation, and allow_claim/2 policy. Make kura optional so OIDC-only apps don't need a database. Simplify security callbacks to use the shared actor session. * fix: remove unused NOVA_AUTH_ACTOR_SESSION_KEY macro Hank flagged it as dead code — the session key is defined locally in nova_auth_actor instead. * feat: make PBKDF2 iterations configurable Read from `{nova_auth, [{pbkdf2_iterations, N}]}` app env. Defaults to 600,000 (OWASP recommendation). Lower values trade security margin for speed in non-banking contexts. --- src/nova_auth_password.erl | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/nova_auth_password.erl b/src/nova_auth_password.erl index 606a61e..ef025c1 100644 --- a/src/nova_auth_password.erl +++ b/src/nova_auth_password.erl @@ -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).". @@ -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); @@ -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). From a536969b1bcd6ae760985b2f2e6c26d1f55e2cd2 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 30 Mar 2026 19:07:42 +0100 Subject: [PATCH 5/8] feat: add OIDC behaviour and JWT validation (#4) * feat: add OIDC behaviour and JWT validation module nova_auth_oidc defines the callback for OIDC provider configuration (providers, scopes, claims mapping). nova_auth_oidc_jwt validates ID tokens and maps claims to actor maps via nova_auth_claims. * fix: suppress hank false positive for behaviour callback --- rebar.config | 6 +++++ src/nova_auth_oidc.erl | 53 +++++++++++++++++++++++++++++++++++++ src/nova_auth_oidc_jwt.erl | 54 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 src/nova_auth_oidc.erl create mode 100644 src/nova_auth_oidc_jwt.erl diff --git a/rebar.config b/rebar.config index 92dca1c..f306353 100644 --- a/rebar.config +++ b/rebar.config @@ -27,6 +27,12 @@ {xref_checks, [undefined_function_calls, undefined_functions]}. +{hank, [ + {ignore, [ + {"src/nova_auth_oidc.erl", unused_callbacks} + ]} +]}. + {ct_opts, [ {dir, "test"}, {extra_src_dirs, ["test"]} diff --git a/src/nova_auth_oidc.erl b/src/nova_auth_oidc.erl new file mode 100644 index 0000000..4cf1ed2 --- /dev/null +++ b/src/nova_auth_oidc.erl @@ -0,0 +1,53 @@ +-module(nova_auth_oidc). +-moduledoc ~""" +Behaviour for OIDC provider configuration. Implementing modules define +provider endpoints, client credentials, scopes, and claims mapping. + +Example: +``` +-module(my_oidc_config). +-behaviour(nova_auth_oidc). +-export([config/0]). + +config() -> + #{ + providers => #{ + google => #{ + client_id => os:getenv("GOOGLE_CLIENT_ID"), + client_secret => os:getenv("GOOGLE_CLIENT_SECRET"), + discovery_url => ~"https://accounts.google.com/.well-known/openid-configuration" + } + }, + scopes => [~"openid", ~"profile", ~"email"], + claims_mapping => #{ + ~"sub" => provider_uid, + ~"email" => provider_email, + ~"name" => provider_display_name + } + }. +``` +""". + +-export_type([oidc_config/0, provider_config/0]). + +-type provider_config() :: #{ + client_id := binary() | string(), + client_secret := binary() | string(), + discovery_url => binary(), + authorize_url => binary(), + token_url => binary(), + userinfo_url => binary(), + jwks_uri => binary() +}. + +-type oidc_config() :: #{ + providers := #{atom() => provider_config()}, + base_url => binary(), + auth_path_prefix => binary(), + scopes => [binary()], + claims_mapping => #{binary() => atom()} | {module(), atom()}, + on_success => {redirect, binary()} | {status, pos_integer()}, + on_failure => {redirect, binary()} | {status, pos_integer()} +}. + +-callback config() -> oidc_config(). diff --git a/src/nova_auth_oidc_jwt.erl b/src/nova_auth_oidc_jwt.erl new file mode 100644 index 0000000..f14e8f3 --- /dev/null +++ b/src/nova_auth_oidc_jwt.erl @@ -0,0 +1,54 @@ +-module(nova_auth_oidc_jwt). +-moduledoc ~""" +Validates OIDC ID tokens (JWTs) against provider configuration. + +Extracts and validates the payload from a JWT, maps claims using +the configured claims mapping, and returns an actor map. +""". + +-export([validate_token/3]). + +-doc ~""" +Validate an OIDC ID token for the given provider. + +Decodes the JWT payload, verifies basic structure, and maps claims +according to the OIDC configuration module's `claims_mapping`. + +Returns `{ok, Actor}` with mapped claims or `{error, Reason}`. +""". +-spec validate_token(module(), atom(), binary()) -> + {ok, nova_auth:actor()} | {error, term()}. +validate_token(ConfigMod, Provider, Token) -> + Config = ConfigMod:config(), + Providers = maps:get(providers, Config, #{}), + case maps:find(Provider, Providers) of + {ok, _ProviderConfig} -> + case decode_jwt_payload(Token) of + {ok, Claims} -> + Mapping = maps:get(claims_mapping, Config, #{}), + Actor = nova_auth_claims:map(Mapping, Claims, #{provider => Provider}), + {ok, #{ + id => maps:get(provider_uid, Actor, maps:get(~"sub", Claims, undefined)), + claims => Actor + }}; + {error, Reason} -> + {error, Reason} + end; + error -> + {error, unknown_provider} + end. + +%% Decode the payload section of a JWT (base64url-encoded JSON). +-spec decode_jwt_payload(binary()) -> {ok, map()} | {error, term()}. +decode_jwt_payload(Token) -> + case binary:split(Token, ~".", [global]) of + [_, PayloadB64, _] -> + try + Decoded = base64:decode(PayloadB64, #{mode => urlsafe, padding => false}), + {ok, json:decode(Decoded)} + catch + _:_ -> {error, invalid_jwt} + end; + _ -> + {error, invalid_jwt_format} + end. From 02451cd69fde3fec54864fcd345918cab7066b40 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 30 Mar 2026 20:15:21 +0200 Subject: [PATCH 6/8] fix: restructure JWT decode for dialyzer compatibility --- src/nova_auth_oidc_jwt.erl | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/nova_auth_oidc_jwt.erl b/src/nova_auth_oidc_jwt.erl index f14e8f3..308a56b 100644 --- a/src/nova_auth_oidc_jwt.erl +++ b/src/nova_auth_oidc_jwt.erl @@ -43,12 +43,21 @@ validate_token(ConfigMod, Provider, Token) -> decode_jwt_payload(Token) -> case binary:split(Token, ~".", [global]) of [_, PayloadB64, _] -> - try - Decoded = base64:decode(PayloadB64, #{mode => urlsafe, padding => false}), - {ok, json:decode(Decoded)} - catch - _:_ -> {error, invalid_jwt} - end; + decode_payload_b64(PayloadB64); _ -> {error, invalid_jwt_format} end. + +-spec decode_payload_b64(binary()) -> {ok, map()} | {error, invalid_jwt}. +decode_payload_b64(PayloadB64) -> + try base64:decode(PayloadB64, #{mode => urlsafe, padding => false}) of + Decoded -> + try json:decode(Decoded) of + Map when is_map(Map) -> {ok, Map}; + _ -> {error, invalid_jwt} + catch + _:_ -> {error, invalid_jwt} + end + catch + _:_ -> {error, invalid_jwt} + end. From bf25acf67f3469427e5ce9136e258b5f0e6521ff Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 31 Mar 2026 12:59:35 +0200 Subject: [PATCH 7/8] fix: use erlang-ci @v2 --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54db2d3..f107c67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,22 +6,22 @@ on: pull_request: branches: [main] -permissions: - contents: write - pull-requests: write - jobs: ci: - uses: Taure/erlang-ci/.github/workflows/ci.yml@v2.0.9 + uses: Taure/erlang-ci/.github/workflows/ci.yml@v2 + permissions: + contents: write + pull-requests: write with: version-file: '.tool-versions' enable-ct: true enable-ex-doc: true enable-hank: true - enable-audit: false + enable-audit: true enable-coverage: true enable-sbom: true enable-sbom-scan: true enable-dependency-submission: true enable-summary: true + extra-services-compose: 'docker-compose.ci.yml' From d04a2e88618fdf01dcc3b0959d2c0f3fdedce7d8 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 31 Mar 2026 13:04:24 +0200 Subject: [PATCH 8/8] fix: start kura application in integration test setup --- test/nova_auth_integration_SUITE.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/nova_auth_integration_SUITE.erl b/test/nova_auth_integration_SUITE.erl index b42df50..598bbd6 100644 --- a/test/nova_auth_integration_SUITE.erl +++ b/test/nova_auth_integration_SUITE.erl @@ -32,7 +32,7 @@ all() -> ]. init_per_suite(Config) -> - {ok, _} = application:ensure_all_started(telemetry), + {ok, _} = application:ensure_all_started(kura), {ok, _} = application:ensure_all_started(pgo), ok = test_auth_repo:start(), setup_tables(),