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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
uses: Taure/erlang-ci/.github/workflows/ci.yml@v2
permissions:
contents: write
pull-requests: write
Expand Down
128 changes: 78 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,52 @@

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

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]).

Expand All @@ -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 |
Expand All @@ -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

Expand Down
110 changes: 110 additions & 0 deletions guides/actor-session.md
Original file line number Diff line number Diff line change
@@ -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()
```
Loading
Loading