diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6b15a58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6797f20 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,11 @@ +name: Release + +on: + push: + branches: [main] + +jobs: + release: + uses: Taure/erlang-ci/.github/workflows/release.yml@v2 + permissions: + contents: write diff --git a/README.md b/README.md index 64b70d8..6cf973a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..515d029 --- /dev/null +++ b/cliff.toml @@ -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" diff --git a/guides/claims-mapping.md b/guides/claims-mapping.md new file mode 100644 index 0000000..48cb3f9 --- /dev/null +++ b/guides/claims-mapping.md @@ -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 +} +``` diff --git a/guides/client-credentials.md b/guides/client-credentials.md new file mode 100644 index 0000000..c2fc31c --- /dev/null +++ b/guides/client-credentials.md @@ -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. +``` diff --git a/guides/configuration.md b/guides/configuration.md new file mode 100644 index 0000000..08d2509 --- /dev/null +++ b/guides/configuration.md @@ -0,0 +1,92 @@ +# Configuration + +All configuration is provided through the `config/0` callback in your module +implementing `-behaviour(nova_auth_oidc)`. The returned map is merged with +defaults and cached in `persistent_term`. + +## Config Keys + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `providers` | `#{atom() => provider_config()}` | *required* | Map of provider name to config | +| `base_url` | `binary()` | `~"http://localhost:8080"` | Application base URL for callback URIs | +| `auth_path_prefix` | `binary()` | `~"/auth"` | URL prefix for auth routes | +| `scopes` | `[binary()]` | `[~"openid", ~"profile", ~"email"]` | Default OIDC scopes | +| `on_success` | `{redirect, binary()}` | `{redirect, ~"/"}` | Action after successful auth | +| `on_failure` | `{status, integer()} \| {redirect, binary()}` | `{status, 401}` | Action on auth failure | +| `claims_mapping` | `#{binary() => atom()} \| {module(), atom()}` | `#{}` | How to map claims to actor | + +## Provider Config + +Each provider entry requires: + +| Key | Type | Required | Description | +|-----|------|----------|-------------| +| `issuer` | `binary()` | yes | OIDC issuer URL (used for discovery) | +| `client_id` | `binary()` | yes | OAuth2 client ID | +| `client_secret` | `binary()` | yes | OAuth2 client secret | +| `scopes` | `[binary()]` | no | Override default scopes for this provider | +| `extra_params` | `#{binary() => binary()}` | no | Extra query parameters for authorization | + +## Full Example + +```erlang +-module(my_oidc_config). +-behaviour(nova_auth_oidc). +-export([config/0]). + +config() -> + #{ + providers => #{ + authentik => #{ + issuer => ~"https://auth.example.com/application/o/myapp", + client_id => os:getenv("AUTHENTIK_CLIENT_ID"), + client_secret => os:getenv("AUTHENTIK_CLIENT_SECRET") + } + }, + base_url => ~"https://myapp.example.com", + auth_path_prefix => ~"/auth", + scopes => [~"openid", ~"profile", ~"email"], + on_success => {redirect, ~"/dashboard"}, + on_failure => {redirect, ~"/login?error=auth_failed"}, + claims_mapping => #{ + ~"sub" => id, + ~"email" => email, + ~"name" => display_name, + ~"groups" => roles + } + }. +``` + +## Session Keys + +During the OIDC flow, temporary state is stored in the Nova session: + +| Key | Lifetime | Contents | +|-----|----------|----------| +| `oidc_nonce` | Login to callback | Cryptographic nonce | +| `oidc_pkce` | Login to callback | PKCE code verifier | +| `oidc_provider` | Login to callback | Provider name | +| `nova_auth_actor` | After callback | Mapped actor (permanent session) | + +The temporary keys are cleaned up after the callback completes. + +## Provider Worker Names + +Each provider gets a worker process registered as +`nova_auth_oidc__`. For example, with module `my_oidc_config` +and provider `authentik`, the worker is `nova_auth_oidc_my_oidc_config_authentik`. + +You can retrieve the name programmatically: + +```erlang +Name = nova_auth_oidc:provider_worker_name(my_oidc_config, authentik). +``` + +## Cache Invalidation + +Configuration is cached in `persistent_term`. To force a refresh: + +```erlang +nova_auth_oidc:invalidate_cache(my_oidc_config). +``` diff --git a/guides/getting-started.md b/guides/getting-started.md new file mode 100644 index 0000000..77b690e --- /dev/null +++ b/guides/getting-started.md @@ -0,0 +1,146 @@ +# Getting Started + +## Installation + +Add `nova_auth_oidc` to your `rebar.config` dependencies: + +```erlang +{deps, [ + {nova_auth_oidc, {git, "https://github.com/Taure/nova_auth_oidc.git", {branch, "main"}}} +]}. +``` + +This pulls in `nova_auth` and `oidcc` as transitive dependencies. + +## Configuration + +Create a module implementing the `nova_auth_oidc` behaviour: + +```erlang +-module(my_oidc_config). +-behaviour(nova_auth_oidc). +-export([config/0]). + +config() -> + #{ + providers => #{ + authentik => #{ + issuer => ~"https://auth.example.com/application/o/myapp", + client_id => os:getenv("AUTHENTIK_CLIENT_ID"), + client_secret => os:getenv("AUTHENTIK_CLIENT_SECRET") + } + }, + base_url => ~"https://myapp.example.com", + claims_mapping => #{ + ~"sub" => id, + ~"email" => email, + ~"name" => display_name, + ~"groups" => roles + } + }. +``` + +## Start Providers + +In your application's `start/2`, start the OIDC provider workers: + +```erlang +-module(my_app). +-behaviour(application). +-export([start/2, stop/1]). + +start(_Type, _Args) -> + nova_auth_oidc:ensure_providers(my_oidc_config), + my_sup:start_link(). + +stop(_State) -> + ok. +``` + +Each provider gets an `oidcc_provider_configuration_worker` that fetches +and caches the discovery document and JWKS from the provider. + +## Routes + +Add login and callback routes to your Nova router: + +```erlang +routes(_Env) -> + [ + %% Auth routes (must be public) + #{prefix => ~"/auth", + security => false, + routes => [ + {~"/:provider/login", fun nova_auth_oidc_controller:login/1, + #{auth_mod => my_oidc_config}}, + {~"/:provider/callback", fun nova_auth_oidc_controller:callback/1, + #{auth_mod => my_oidc_config}} + ]}, + + %% Protected web routes (session-based) + #{prefix => ~"/dashboard", + security => nova_auth_security:require_authenticated(), + routes => [ + {~"/", fun my_dashboard_controller:index/1, #{methods => [get]}} + ]}, + + %% Protected API routes (JWT bearer) + #{prefix => ~"/api", + security => nova_auth_oidc_security:require_bearer(my_oidc_config), + routes => [ + {~"/resources", fun my_resource_controller:index/1, #{methods => [get]}} + ]} + ]. +``` + +The `:provider` path parameter selects which provider to use. With the config +above, the login URL is `/auth/authentik/login`. + +## Access Actor in Controllers + +After authentication, the actor is available in the request map: + +```erlang +index(#{auth_data := Actor} = _Req) -> + #{id := Id, email := Email} = Actor, + {json, #{id => Id, email => Email}}. +``` + +## Provider Setup: Authentik + +1. Create an OAuth2/OpenID Provider in Authentik admin +2. Set the redirect URI to `https://myapp.example.com/auth/authentik/callback` +3. Copy the Client ID and Client Secret to your environment variables +4. The issuer URL is `https://your-authentik.example.com/application/o/` + +## Provider Setup: Google + +1. Create OAuth 2.0 credentials in Google Cloud Console +2. Set the redirect URI to `https://myapp.example.com/auth/google/callback` +3. The issuer is `https://accounts.google.com` + +## Multiple Providers + +Add more providers to the config map: + +```erlang +config() -> + #{ + providers => #{ + authentik => #{ + issuer => ~"https://auth.example.com/application/o/myapp", + client_id => os:getenv("AUTHENTIK_CLIENT_ID"), + client_secret => os:getenv("AUTHENTIK_CLIENT_SECRET") + }, + google => #{ + issuer => ~"https://accounts.google.com", + client_id => os:getenv("GOOGLE_CLIENT_ID"), + client_secret => os:getenv("GOOGLE_CLIENT_SECRET"), + scopes => [~"openid", ~"email"] %% per-provider scope override + } + }, + ... + }. +``` + +Login URLs: `/auth/authentik/login`, `/auth/google/login`. diff --git a/guides/jwt-bearer.md b/guides/jwt-bearer.md new file mode 100644 index 0000000..6826521 --- /dev/null +++ b/guides/jwt-bearer.md @@ -0,0 +1,86 @@ +# JWT Bearer Validation + +`nova_auth_oidc_jwt` validates JWTs from the `Authorization: Bearer ` +header using the OIDC provider's JWKS. This is useful for protecting API routes +that receive tokens directly from Authentik or other providers. + +## Route Protection + +Use `nova_auth_oidc_security:require_bearer/1` to protect API routes: + +```erlang +#{prefix => ~"/api", + security => nova_auth_oidc_security:require_bearer(my_oidc_config), + routes => [ + {~"/resources", fun my_resource_controller:index/1, #{methods => [get]}} + ]} +``` + +The security callback extracts the Bearer token, validates it against the +provider's JWKS, checks claims, applies the claims mapping, and passes the +resulting actor as `auth_data`. + +## Mixed Session + Bearer + +Use `nova_auth_oidc_security:require_any/1` for routes that accept both +browser sessions and API tokens: + +```erlang +#{prefix => ~"/api", + security => nova_auth_oidc_security:require_any(my_oidc_config), + routes => [...]} +``` + +This tries session auth first (via `nova_auth_actor:fetch/1`), then falls +back to JWT bearer validation. + +## How Validation Works + +1. Extract `Authorization: Bearer ` header +2. Get the JWKS from the provider's cached configuration worker +3. Verify the JWT signature using `jose_jwt:verify/2` +4. Validate `exp` (not expired) and `aud` (matches client_id) +5. Apply `claims_mapping` from config to build the actor +6. Return `{ok, Actor}` or `{error, Reason}` + +## Direct API Usage + +You can validate tokens programmatically: + +```erlang +%% Validate from request +case nova_auth_oidc_jwt:validate_bearer(my_oidc_config, Req) of + {ok, Actor} -> handle_authenticated(Actor); + {error, Reason} -> handle_error(Reason) +end. + +%% Validate a specific provider +case nova_auth_oidc_jwt:validate_bearer(my_oidc_config, authentik, Req) of + {ok, Actor} -> ok; + {error, _} -> unauthorized +end. + +%% Validate a raw token string +case nova_auth_oidc_jwt:validate_token(my_oidc_config, authentik, TokenBinary) of + {ok, Actor} -> ok; + {error, _} -> unauthorized +end. +``` + +## Error Types + +| Error | Description | +|-------|-------------| +| `missing_bearer` | No `Authorization: Bearer` header | +| `no_providers` | No providers configured | +| `provider_not_available` | Provider worker not running | +| `invalid_signature` | JWT signature verification failed | +| `missing_exp` | JWT has no `exp` claim | +| `token_expired` | JWT `exp` is in the past | +| `invalid_audience` | JWT `aud` doesn't match client_id | + +## JWKS Caching + +The JWKS is fetched and cached by the `oidcc_provider_configuration_worker` +(the same worker used for OIDC login flows). Keys are refreshed automatically +when the provider rotates them. diff --git a/rebar.config b/rebar.config index fa14566..1cb2d2e 100644 --- a/rebar.config +++ b/rebar.config @@ -1,8 +1,8 @@ {erl_opts, [debug_info, warnings_as_errors]}. {deps, [ - {nova_auth, {git, "https://github.com/Taure/nova_auth.git", {branch, "main"}}}, - {nova, {git, "https://github.com/novaframework/nova.git", {branch, "master"}}}, + {nova_auth, "~> 0.1"}, + nova, {oidcc, "~> 3.7"} ]}. @@ -60,7 +60,7 @@ {hex, [{doc, #{provider => ex_doc}}]}. {ex_doc, [ - {source_url, <<"https://github.com/Taure/nova_auth_oidc">>}, + {source_url, <<"https://github.com/novaframework/nova_auth_oidc">>}, {main, <<"readme">>}, {extras, [ <<"README.md">>, diff --git a/src/nova_auth_oidc.app.src b/src/nova_auth_oidc.app.src index 2662d41..4a72435 100644 --- a/src/nova_auth_oidc.app.src +++ b/src/nova_auth_oidc.app.src @@ -6,6 +6,6 @@ {applications, [kernel, stdlib, crypto, nova, nova_auth, oidcc]}, {env, []}, {modules, []}, - {licenses, ["MIT"]}, - {links, [{"GitHub", "https://github.com/Taure/nova_auth_oidc"}]} + {licenses, ["Apache-2.0"]}, + {links, [{"GitHub", "https://github.com/novaframework/nova_auth_oidc"}]} ]}.