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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v2
permissions:
contents: write
pull-requests: write
with:
enable-summary: true
secrets: inherit
11 changes: 11 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name: Release

on:
push:
branches: [main]

jobs:
release:
uses: Taure/erlang-ci/.github/workflows/release.yml@v2
permissions:
contents: write
59 changes: 49 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
OpenID Connect authentication for [Nova](https://github.com/novaframework/nova) web applications.

Provides OIDC login flows, JWT bearer validation, token introspection, and
client credentials (M2M) all integrated with [nova_auth](https://github.com/Taure/nova_auth)'s
client credentials (M2M) -- all integrated with [nova_auth](https://github.com/Taure/nova_auth)'s
unified actor session.

## Features

- **Multi-provider OIDC** Authentik, Google, GitHub, Keycloak, etc.
- **JWT bearer validation** protect API routes with provider-issued JWTs
- **Token introspection** check revocation status (RFC 7662)
- **Client credentials** machine-to-machine tokens with caching
- **Claims mapping** transform provider claims to actor maps
- **Nova integration** security callbacks, plugins, route protection
- **Multi-provider OIDC** -- Authentik, Google, GitHub, Keycloak, etc.
- **JWT bearer validation** -- protect API routes with provider-issued JWTs
- **Token introspection** -- check revocation status (RFC 7662)
- **Client credentials** -- machine-to-machine tokens with caching
- **Claims mapping** -- transform provider claims to actor maps via [nova_auth_claims](https://github.com/Taure/nova_auth)
- **Nova integration** -- security callbacks, plugins, route protection

## Quick Start

Expand Down Expand Up @@ -58,11 +58,50 @@ routes(_Env) ->
routes => [...]}].
```

## Modules

| Module | Description |
|--------|-------------|
| `nova_auth_oidc` | Behaviour-based config, provider worker management |
| `nova_auth_oidc_controller` | Login redirect and OAuth callback endpoints |
| `nova_auth_oidc_plugin` | Route protection plugin (session-based) |
| `nova_auth_oidc_jwt` | JWT bearer token validation via provider JWKS |
| `nova_auth_oidc_security` | Security callbacks: `require_bearer/1`, `require_any/1` |
| `nova_auth_oidc_introspect` | Token introspection (RFC 7662) |
| `nova_auth_oidc_client_credentials` | Client credentials flow with caching |

## How It Works

1. User visits `/auth/authentik/login`
2. Controller generates nonce + PKCE, stores in session, redirects to provider
3. User authenticates at Authentik
4. Authentik redirects to `/auth/authentik/callback?code=...`
5. Controller exchanges code for tokens via `oidcc`
6. Controller retrieves userinfo from provider
7. Claims are mapped to an actor via `nova_auth_claims`
8. Actor is stored in session via `nova_auth_actor`
9. User is redirected to the success URL

From this point, `nova_auth_security:require_authenticated()` works for all
protected routes.

## Guides

- [Getting Started](guides/getting-started.md) -- Installation and first setup
- [Configuration](guides/configuration.md) -- Full config reference
- [JWT Bearer](guides/jwt-bearer.md) -- Protecting API routes with JWTs
- [Claims Mapping](guides/claims-mapping.md) -- Transforming provider claims
- [Client Credentials](guides/client-credentials.md) -- Machine-to-machine auth

## Dependencies

- [nova_auth](https://github.com/Taure/nova_auth) — unified actor session
- [oidcc](https://github.com/erlef/oidcc) — ERLEF OpenID Connect Certified client
- [nova](https://github.com/novaframework/nova) — web framework
- [nova_auth](https://github.com/Taure/nova_auth) -- unified actor session, claims mapping, policies
- [oidcc](https://github.com/erlef/oidcc) -- ERLEF OpenID Connect Certified client
- [nova](https://github.com/novaframework/nova) -- web framework

## Requirements

- Erlang/OTP 28+

## License

Expand Down
38 changes: 38 additions & 0 deletions cliff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[changelog]
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
trim = true

[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^docs", group = "Documentation" },
{ message = "^refactor", group = "Refactor" },
{ message = "^test", group = "Testing" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore", group = "Miscellaneous" },
{ message = "^ci", skip = true },
]
protect_breaking_commits = false
tag_pattern = "v[0-9].*"
sort_commits = "oldest"
150 changes: 150 additions & 0 deletions guides/claims-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Claims Mapping

After a successful OIDC callback or JWT validation, raw provider claims
need to be transformed into an actor map. The `claims_mapping` config option
controls this transformation using `nova_auth_claims` from the nova_auth library.

## Default Behaviour

With no `claims_mapping` configured (or `#{}`), the controller creates a
minimal actor:

```erlang
#{
id => maps:get(~"sub", Userinfo), %% falls back to ~"email"
provider => authentik,
claims => Userinfo %% raw claims preserved
}
```

## Static Mapping

Map binary claim keys to atom actor keys:

```erlang
claims_mapping => #{
~"sub" => id,
~"email" => email,
~"name" => display_name,
~"groups" => roles
}
```

Given Authentik claims:

```erlang
#{
~"sub" => ~"abc123",
~"email" => ~"jane@example.com",
~"name" => ~"Jane Doe",
~"groups" => [~"admins", ~"developers"],
~"iss" => ~"https://auth.example.com/..."
}
```

The resulting actor is:

```erlang
#{
id => ~"abc123",
provider => authentik,
email => ~"jane@example.com",
display_name => ~"Jane Doe",
roles => [~"admins", ~"developers"]
}
```

Note that `provider` is always added automatically. Claims not in the mapping
(like `iss`) are dropped.

## Callback Mapping

For complex transformations, use a `{Module, Function}` tuple:

```erlang
claims_mapping => {my_claims, map_authentik}
```

The function receives the raw claims map and must return an actor map:

```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
}.
```

## Using Claims with Policies

After mapping, you can use `nova_auth_policy:allow_claim/2` for authorization:

```erlang
%% With static mapping: ~"groups" => roles
%% Actor has: #{roles => [~"admins", ~"developers"], ...}

nova_auth_policy:allow_claim(roles, ~"admins")
%% Checks if ~"admins" is in the actor's roles list

%% With callback mapping: groups → role atom
%% Actor has: #{role => admin, ...}

nova_auth_policy:allow_claim(role, [admin, editor])
%% Checks if role is admin or editor
```

## Provider-Specific Examples

### Authentik

Authentik includes groups and entitlements in tokens:

```erlang
claims_mapping => #{
~"sub" => id,
~"email" => email,
~"preferred_username" => username,
~"name" => display_name,
~"groups" => roles
}
```

### Keycloak

Keycloak nests roles under `realm_access.roles`. Use a callback:

```erlang
claims_mapping => {my_claims, map_keycloak}

%% my_claims.erl
map_keycloak(Claims) ->
RealmAccess = maps:get(~"realm_access", Claims, #{}),
Roles = maps:get(~"roles", RealmAccess, []),
#{
id => maps:get(~"sub", Claims),
email => maps:get(~"email", Claims, undefined),
roles => Roles
}.
```

### Google

Google claims are flat:

```erlang
claims_mapping => #{
~"sub" => id,
~"email" => email,
~"name" => display_name,
~"picture" => avatar_url
}
```
92 changes: 92 additions & 0 deletions guides/client-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Client Credentials

`nova_auth_oidc_client_credentials` implements the OAuth2 client credentials
grant for machine-to-machine (M2M) authentication. Tokens are cached in
`persistent_term` and automatically refreshed when expired.

## Usage

```erlang
%% Get a cached or fresh access token
{ok, AccessToken} = nova_auth_oidc_client_credentials:get_token(my_oidc_config, authentik).

%% Use it to call another service
Headers = [{~"authorization", <<~"Bearer ", AccessToken/binary>>}],
httpc:request(get, {"https://api.example.com/data", Headers}, [], []).
```

## With Specific Scopes

```erlang
{ok, Token} = nova_auth_oidc_client_credentials:get_token(
my_oidc_config, authentik, [~"read:users", ~"write:users"]
).
```

## Force Refresh

If a token is rejected (e.g., revoked server-side), force a refresh:

```erlang
{ok, FreshToken} = nova_auth_oidc_client_credentials:refresh(my_oidc_config, authentik).
```

## How Caching Works

1. First call fetches a token from the provider via `oidcc:client_credentials_token/4`
2. Token and expiry are cached in `persistent_term`
3. Subsequent calls return the cached token if it hasn't expired (with 30s buffer)
4. Expired tokens are automatically refreshed on the next call
5. Cache key: `{nova_auth_oidc_cc, AuthMod, Provider}`

## Token Introspection

To check if a received token is still active (not revoked):

```erlang
case nova_auth_oidc_introspect:introspect(my_oidc_config, authentik, ReceivedToken) of
{ok, #{active := true, username := Username}} ->
handle_request(Username);
{ok, #{active := false}} ->
{status, 401};
{error, Reason} ->
logger:error(~"Introspection failed: ~p", [Reason]),
{status, 500}
end.
```

The introspection response includes:

| Field | Type | Description |
|-------|------|-------------|
| `active` | `boolean()` | Whether the token is active |
| `client_id` | `binary()` | Client that requested the token |
| `exp` | `pos_integer() \| undefined` | Expiration timestamp |
| `scope` | `[binary()]` | Granted scopes |
| `username` | `binary() \| undefined` | Resource owner username |
| `token_type` | `binary() \| undefined` | Token type (e.g., `Bearer`) |
| `iss` | `binary() \| undefined` | Token issuer |
| `extra` | `map()` | Additional provider-specific fields |

## Provider Setup: Authentik

For client credentials in Authentik:

1. Create a new OAuth2 Provider with "Machine-to-machine" flow
2. Under "Advanced protocol settings", enable the `client_credentials` grant type
3. Assign the provider to an Application
4. The client ID and secret from the provider config are used automatically

## Error Handling

```erlang
case nova_auth_oidc_client_credentials:get_token(my_oidc_config, authentik) of
{ok, Token} ->
use_token(Token);
{error, {grant_type_not_supported, client_credentials}} ->
%% Provider doesn't support client credentials
logger:error(~"Provider does not support client_credentials grant");
{error, Reason} ->
logger:error(~"Failed to get M2M token: ~p", [Reason])
end.
```
Loading
Loading