Skip to main content

README.md

# AttestoPhoenix

An opinionated Phoenix/Ecto OAuth 2.0 / OIDC authorization server on top of
[attesto](https://hex.pm/packages/attesto).

**attesto brings the protocol, attesto_phoenix brings transport + persistence;
you bring principals, keys, and policy.**

`attesto` is a transport-agnostic library of OAuth/OIDC primitives: JWT access
tokens, JWKS/key handling, DPoP, mTLS, PKCE, scope algebra, private-key client
assertions, signed request objects, and the token-lifecycle building blocks.
`attesto_phoenix` wires those primitives into a running server:

- HTTP endpoints (authorization, token, PAR, revocation, discovery, JWKS,
  UserInfo, optional dynamic registration) mounted into your router with one
  macro.
- Protected-resource plugs that verify Bearer JWTs and enforce DPoP / mTLS
  sender-constraint binding.
- Ecto-backed implementations of the attesto store behaviours for authorization
  codes, refresh tokens, and (for clustered deployments) DPoP nonces and proof
  `jti` replay records.

It deliberately does **not** own your client registry, principal store, secret
hashing, scope catalog, or audit log. Those are application policy and are
supplied through a small set of neutral configuration callbacks.

## Positioning vs. attesto core

| Concern | `attesto` (core) | `attesto_phoenix` (this package) |
| --- | --- | --- |
| JWT mint/verify, JWKS, DPoP, mTLS, PKCE, scopes | yes | reuses core |
| `private_key_jwt`, signed request objects, token exchange primitives | yes | wires into endpoints |
| Grant orchestration primitives | yes | reuses core |
| HTTP endpoints + router macro | no | yes |
| Protected-resource plugs | core plug building blocks | Phoenix-friendly wrappers |
| Ecto-backed token stores | store *behaviours* only | Ecto *implementations* |
| Client registry, principals, keys, audit | no | supplied via callbacks |

If you only need the protocol primitives and want to build your own transport,
depend on `attesto` directly. If you want a batteries-mostly-included Phoenix
server, use `attesto_phoenix`.

## Installation

Add `attesto_phoenix` to your dependencies:

```elixir
def deps do
  [
    {:attesto_phoenix, "~> 0.6"}
  ]
end
```

## Configuration

All behavior is centralized in `AttestoPhoenix.Config`. Anything that is
inherently application policy is a neutral callback rather than a baked-in
assumption.

```elixir
config :my_app, AttestoPhoenix.Config,
  # --- required ---
  issuer: "https://auth.example.com",
  keystore: MyApp.Keystore,            # implements Attesto.Keystore
  repo: MyApp.Repo,                    # Ecto.Repo for the token stores

  # client lookup + secret verification (you own the client registry)
  load_client: &MyApp.Clients.fetch/1,
  #   (client_id -> {:ok, client} | {:error, :not_found} | {:error, :revoked})
  verify_client_secret: &MyApp.Clients.verify_secret/2,
  #   (client, presented_secret -> boolean) -- constant time
  client_jwks: &MyApp.Clients.jwks/1,
  #   (client -> {:ok, jwks} | jwks), for private_key_jwt and request objects

  # subject/principal resolution for protected-resource auth
  load_principal: &MyApp.Principals.fetch/1,
  #   (subject_id -> {:ok, principal} | {:error, :not_found})

  # --- optional policy ---
  scopes_supported: ["profile", "email", "read:*", "write:*"],
  authorize_scope: &MyApp.Scopes.authorize/2,
  #   (client, requested_scope -> {:ok, granted} | {:error, :invalid_scope})
  on_event: &MyApp.Audit.record/1,     # (%AttestoPhoenix.Event{} -> any)
  send_error: &MyApp.OAuthErrors.render/3,
  #   (conn, status, body_map -> conn), optional custom OAuth error envelope

  # --- optional deployment + features ---
  require_https: true,
  trusted_proxies: ["10.0.0.0/8"],     # honor X-Forwarded-* only from these
  access_token_ttl: 900,
  refresh_token_ttl: 1_209_600,
  authorization_code_ttl: 60,
  dpop_enabled: true,
  dpop_nonce_required: false,
  mtls_enabled: false,                 # if true, also set :cert_der
  registration_enabled: false          # if true, also set :register_client
```

Build the validated struct wherever you need it:

```elixir
config = AttestoPhoenix.Config.from_otp_app(:my_app)
```

Required keys are validated at build time; a missing key (or a missing
dependency such as `:cert_der` when mTLS is enabled) raises immediately so
misconfiguration fails fast.

### The callbacks, in OAuth terms

- **client lookup** -> `:load_client`
- **client secret verification** -> `:verify_client_secret`
- **client public keys** -> `:client_jwks`
- **subject/principal resolution** -> `:load_principal`
- **scope catalog / narrowing** -> `:scopes_supported` and/or `:authorize_scope`
- **audit / telemetry** -> `:on_event` (optional, no-op by default)
- **error envelope / transport rendering** -> `:send_error`,
  `:www_authenticate`, `:no_store` (optional)
- **dynamic client persistence** -> `:register_client` (only with registration)
- **mTLS certificate extraction** -> `:cert_der` (only with mTLS)
- **HTTPS / proxy trust** -> `:require_https` + `:trusted_proxies`

## Mounting the routes

Use the router macro to mount the server endpoints under a scope you choose:

```elixir
defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  use AttestoPhoenix.Router

  pipeline :oauth do
    plug :accepts, ["json"]
  end

  scope "/" do
    pipe_through :oauth
    attesto_routes()
  end
end
```

`attesto_routes/1` mounts:

- `GET  /.well-known/oauth-authorization-server` (RFC 8414 metadata)
- `GET  /.well-known/openid-configuration` (OIDC Discovery metadata)
- `GET  /.well-known/jwks.json` (RFC 7517 JWK Set)
- `GET  /oauth/authorize`
- `POST /oauth/token`
- `POST /oauth/par` (RFC 9126)
- `POST /oauth/revoke` (RFC 7009)
- `POST /oauth/register` (RFC 7591, only when `registration_enabled: true`)
- `DELETE /oauth/register/:client_id` (RFC 7592, with registration)
- `GET  /oauth/userinfo`
- `POST /oauth/userinfo`

Discovery and JWKS are public; the token and revocation endpoints authenticate
the client via your `:load_client` / `:verify_client_secret` callbacks.
The token endpoint also accepts `private_key_jwt` when `:client_jwks` is wired,
and supports authorization-code, refresh-token, client-credentials, and OAuth
token-exchange grants. The PAR endpoint accepts the same confidential-client
secret methods plus `private_key_jwt`, then stores the authorization request
behind a one-time `request_uri`.

## Protecting resources

```elixir
pipeline :api_protected do
  plug AttestoPhoenix.Plug.Authenticate
end

scope "/api", MyAppWeb do
  pipe_through [:api, :api_protected]

  scope "/reports" do
    plug AttestoPhoenix.Plug.RequireScopes, "read:reports"
    get "/", ReportController, :index
  end
end
```

`AttestoPhoenix.Plug.Authenticate` verifies the Bearer JWT, enforces DPoP and
mTLS binding when enabled, resolves the subject via `:load_principal`, emits
neutral `:auth_succeeded` / `:auth_denied` events through `:on_event`, and
assigns:

- `conn.assigns.attesto_claims` - the verified JWT claims
- `conn.assigns.attesto_principal` - the host principal returned by
  `:load_principal`
- `conn.assigns.attesto_context` - a neutral `%{subject, client_id, scope,
  claims, cnf, principal}` map

`AttestoPhoenix.Plug.RequireScopes` enforces route-level scope authorization
using `Attesto.Scope` grant-form algebra. It accepts either a single scope
string or a list of required scopes.

For first-party web flows, keep cookie semantics in your app and pass a generic
credential extractor to the plug:

```elixir
plug AttestoPhoenix.Plug.Authenticate,
  credential_from_conn: &MyAppWeb.Auth.access_token_from_cookie/1
```

The extractor returns `{:ok, :bearer, token}`, `{:ok, :dpop, token}`, or
`:missing`. Attesto still verifies the token through the same JWT/DPoP/mTLS
path; the cookie format and CSRF policy remain host concerns.

## Database migration

The library owns four operational tables backing the attesto store behaviours:
`authorizations`, `refresh_tokens`, `dpop_nonces`, and `dpop_replays`. It does
**not** own a clients table (that is yours, behind `:load_client`). The default
PAR store is single-node ETS; clustered deployments should provide a
`AttestoPhoenix.PARStore` backed by shared storage.

Generate the migration into your app:

```bash
mix attesto_phoenix.gen.migration --repo MyApp.Repo
```

Then run it:

```bash
mix ecto.migrate
```

Single-node deployments may skip the Ecto nonce/replay tables and wire
attesto's in-memory ETS implementations via `:nonce_store` and `:replay_check`;
the Ecto variants exist for clustered correctness.

## License

MIT. See [LICENSE](LICENSE).