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
5 changes: 5 additions & 0 deletions .gitbook.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
root: ./docs/

structure:
readme: README.md
summary: SUMMARY.md
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/docs/
/lib/
/.shards/
/.vscode/
Expand Down
1,569 changes: 139 additions & 1,430 deletions README.md

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
description: Production-ready OAuth 2.0, OpenID Connect, and FAPI authentication library for Crystal.
icon: shield-halved
---

# Welcome to Authly

**Authly** is a strongly-typed, zero-framework Crystal shard for building **OAuth 2.0**, **OpenID Connect (OIDC)**, and **Financial-grade API (FAPI)** Authorization Servers and Resource Servers on top of Crystal's standard `HTTP::Handler`.

---

## Documentation Sections

<table data-view="cards">
<thead>
<tr>
<th></th>
<th></th>
<th data-hidden data-card-target data-type="content-ref"></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>🎓 Tutorials</strong></td>
<td>Step-by-step guided lessons that take you from a blank Crystal project to a working OAuth2 server and protected API.</td>
<td><a href="tutorials/README.md">tutorials/README.md</a></td>
</tr>
<tr>
<td><strong>🛠️ How-To Guides</strong></td>
<td>Goal-oriented recipes for Redis stores, scope enforcement, PAR, JARM, mTLS, key rotation, and Dynamic Client Registration.</td>
<td><a href="how-to/README.md">how-to/README.md</a></td>
</tr>
<tr>
<td><strong>📖 Reference</strong></td>
<td>Complete technical specifications for <code>Authly::Configuration</code>, HTTP endpoints, Crystal interfaces, and error codes.</td>
<td><a href="reference/README.md">reference/README.md</a></td>
</tr>
<tr>
<td><strong>💡 Explanation</strong></td>
<td>Deep dives into Authly's architecture, refresh token rotation families, atomic single-use credentials, and threat model.</td>
<td><a href="explanation/README.md">explanation/README.md</a></td>
</tr>
</tbody>
</table>

---

## Quick Installation

{% tabs %}
{% tab title="shard.yml" %}
```yaml title="shard.yml"
dependencies:
authly:
github: azutoolkit/authly
```
{% endtab %}

{% tab title="Terminal" %}
```bash
shards install
```
{% endtab %}
{% endtabs %}

---

## Capabilities at a Glance

| Category | Supported Specifications & Features |
| :--- | :--- |
| **OAuth 2.0 Grants** | Authorization Code (`authorization_code`), PKCE (`S256` / `plain`), Client Credentials (`client_credentials`), Refresh Token (`refresh_token`), Device Authorization (`urn:ietf:params:oauth:grant-type:device_code`), Cryptographic Challenge-Response (`challenge_response` / `urn:ietf:params:oauth:grant-type:challenge`), Password (`password`), Implicit (`implicit`) |
| **OpenID Connect** | ID Tokens, UserInfo Endpoint (`/oauth/userinfo`), OIDC Discovery (`/.well-known/openid-configuration`), RFC 8414 Metadata (`/.well-known/oauth-authorization-server`), JWKS (`/.well-known/jwks.json`) |
| **FAPI & Hardening** | Pushed Authorization Requests (PAR / RFC 9126), JWT Secured Authorization Response Mode (JARM / RFC 9101), Certificate-Bound mTLS Tokens (`cnf.x5t#S256` / RFC 8705), Dynamic Client Registration (RFC 7591) |
| **Resource Protection** | `Authly::Handler::Bearer` (RFC 6750), `Authly::Handler::RequireScope` (`all` / `any`), Audience (`aud`) verification, JWT & Opaque token strategies |

---

## Start Building

{% content-ref url="tutorials/getting-started.md" %}
[getting-started.md](tutorials/getting-started.md)
{% endcontent-ref %}

{% content-ref url="tutorials/challenge-response-auth.md" %}
[challenge-response-auth.md](tutorials/challenge-response-auth.md)
{% endcontent-ref %}
34 changes: 34 additions & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Table of contents

* [Welcome to Authly](README.md)

## Tutorials

* [Overview](tutorials/README.md)
* [Getting Started: First OAuth2 Server & API](tutorials/getting-started.md)
* [Interactive Sign-In with PKCE & OIDC](tutorials/authorization-code-pkce.md)
* [Cryptographic Challenge-Response Auth](tutorials/challenge-response-auth.md)

## How-To Guides

* [Overview](how-to/README.md)
* [Implement Custom Clients, Owners & Redis Stores](how-to/custom-stores-and-models.md)
* [Protect Resource APIs & Mount in Azu](how-to/protect-resource-apis.md)
* [Enable FAPI & Advanced Security (PKCE, PAR, JARM, mTLS)](how-to/fapi-and-advanced-security.md)
* [Configure Asymmetric Keys, Key Rotation & JWKS](how-to/key-rotation-and-jwks.md)
* [Configure Dynamic Client Registration & Device Flow](how-to/dynamic-client-registration.md)

## Reference

* [Overview](reference/README.md)
* [Configuration Reference](reference/configuration.md)
* [HTTP Endpoints & Wire Protocol](reference/http-endpoints.md)
* [Crystal API, Grants & Store Interfaces](reference/grants-and-interfaces.md)
* [Errors & RFC 6750 Challenge Responses](reference/errors.md)

## Explanation

* [Overview](explanation/README.md)
* [Architecture & Design Principles](explanation/architecture.md)
* [Token Strategies & Refresh Token Lifecycle](explanation/token-strategies-and-lifecycle.md)
* [Security Model & Multi-Node Deployment](explanation/security-model.md)
20 changes: 20 additions & 0 deletions docs/explanation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
description: Architectural deep-dives into Authly's design principles, token lifecycle, and security model.
icon: lightbulb
---

# Explanation

These pages cover how Authly is designed, how token rotation families work under the hood, and what security invariants you must maintain when deploying across multiple nodes.

{% content-ref url="architecture.md" %}
[architecture.md](architecture.md)
{% endcontent-ref %}

{% content-ref url="token-strategies-and-lifecycle.md" %}
[token-strategies-and-lifecycle.md](token-strategies-and-lifecycle.md)
{% endcontent-ref %}

{% content-ref url="security-model.md" %}
[security-model.md](security-model.md)
{% endcontent-ref %}
61 changes: 61 additions & 0 deletions docs/explanation/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
description: How Authly separates the Authorization Server, Resource Server, host user authentication, and storage interfaces.
icon: sitemap
---

# Architecture & Design Principles

Authly is designed as a composable, zero-framework Crystal OAuth2 and OpenID Connect shard built directly on Crystal's standard `HTTP::Handler`.

---

## 1. Separation of Authorization Server and Resource Server

Authly separates **issuing credentials** from **verifying credentials on API requests**:

```mermaid
flowchart TD
Req["Incoming HTTP Request"] --> Handler["Authly::Handler (Authorization Server)"]
Handler -->|"/oauth/* or /.well-known/*"| OAuthEndpoints["Authorize / Token / PAR / Challenge / Introspect / Revoke / Discovery"]
Handler -->|"All other paths"| Bearer["Authly::Handler::Bearer (Resource Server)"]
Bearer -->|"Verifies Bearer Token & populates context.authly_claims"| Scope["Authly::Handler::RequireScope"]
Scope -->|"Enforces route-level scopes"| App["Your Application Routes"]
```

- **Co-located Deployment**: Mount `Authly::Handler`, `Authly::Handler::Bearer`, and `Authly::Handler::RequireScope` in the same `HTTP::Server` (exempting `/oauth` and `/.well-known` on `Bearer`).
- **Separate Microservices**: Run `Authly::Handler` on a dedicated Authorization Server (`auth.example.com`) and run `Authly::Handler::Bearer` on independent Resource Servers (`api.example.com`), verifying JWTs via the published public key (`config.public_key` or `config.verification_keys`).

---

## 2. Why Authly Does Not Own User Login

Interactive OAuth2 endpoints (`/oauth/authorize` and `/device`) act on behalf of a signed-in human user (the **resource owner**). Authly deliberately does not implement password login forms, session cookies, or MFA screens. Instead, the host application supplies `config.authenticate_user`:

```crystal
config.authenticate_user = ->(context : HTTP::Server::Context) { MySession.current_user_id(context) }
```

{% hint style="success" %}
This boundary provides two critical security properties:
1. **No Untrusted Subject Parameters**: Authly never reads `user_id` or `consent` from query strings or POST bodies. The authenticated owner's identity comes exclusively from `config.authenticate_user`.
2. **Fail-Closed by Default**: Until `config.authenticate_user` is configured, `/oauth/authorize` and `/device` refuse every request rather than issuing tokens for an unverified user.
{% endhint %}

---

## 3. Pluggable Store Architecture

Authly isolates all state behind six focused interfaces so each concern can be backed by the right storage primitive:

| Interface | What It Stores | Key Requirement |
| :--- | :--- | :--- |
| `TokenStore` | Opaque token payloads, revoked `jti`s, and refresh token families | Atomic `consume(jti)` and `StoreUnavailable` signaling |
| `StateStore` | Anti-CSRF nonces for consent and device forms | Atomic single-use `consume(state)` |
| `ChallengeStore` | Cryptographic challenge nonces (`POST /oauth/challenge`) | Atomic single-use `consume!` / `record_attempt!` |
| `PARStore` | Pushed authorization request parameters (`request_uri`) | Atomic single-use `consume(request_uri)` |
| `DeviceCodeStore` | Device flow `device_code` and `user_code` state | Indexed by both `device_code` and `user_code` |
| `AttemptLimiter` | Rate-limit counters per source/owner within a sliding window | Atomic `increment(key, window)` and fail-closed capacity |

{% content-ref url="../how-to/custom-stores-and-models.md" %}
[custom-stores-and-models.md](../how-to/custom-stores-and-models.md)
{% endcontent-ref %}
64 changes: 64 additions & 0 deletions docs/explanation/security-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
description: How Authly prevents TOCTOU replay attacks, timing side-channels, algorithm confusion, and resource exhaustion.
icon: shield-check
---

# Security Model & Multi-Node Deployment

Authly's security design follows **RFC 6749**, **RFC 6750**, **OAuth 2.0 Security Best Current Practice (RFC 9700)**, and **FAPI** principles.

---

## 1. Atomic Single-Use Credentials

Several credentials in OAuth2 must be **strictly single-use**:
- Authorization codes (`jti` in `TokenStore#consume`)
- Refresh tokens (`jti` in `TokenStore#consume`)
- Pushed Authorization Request URIs (`request_uri` in `PARStore#consume`)
- Consent & Device form CSRF tokens (`state` in `StateStore#consume`)
- Cryptographic challenges (`challenge_id` in `ChallengeStore#consume!` / `record_attempt!`)

{% hint style="danger" %}
### Why Check-Then-Delete Is Insecure
A naive implementation that checks `if store.valid?(id)` and later calls `store.delete(id)` is vulnerable to a race condition (TOCTOU): two parallel requests presenting the same authorization code or challenge nonce can both pass `valid?` before either executes `delete`.
{% endhint %}

Authly's handlers **never** call `valid?` followed by `delete`. Instead, they rely on a single atomic operation (`consume` / `consume!` / `record_attempt!`).
- In Authly's in-memory stores, this atomicity is enforced with a `Mutex`.
- In multi-node Redis or SQL stores, implement `consume` as a single atomic command (e.g., Redis `SET key 1 NX EX ttl`, `DEL key` returning `1`, or `GETDEL key`).

---

## 2. Cryptographic Challenge-Response Hardening

Authly's Challenge-Response grant (`challenge_response` / `urn:ietf:params:oauth:grant-type:challenge`) defends against credential replay, timing side-channels, and brute-force guessing:

1. **High-Entropy Nonces**: Challenges are 256-bit random values generated with `Random::Secure.hex(32)` and expire after `config.challenge_ttl` (`60.seconds` by default).
2. **Client Binding**: Each challenge is bound to the `client_id` that requested it at `POST /oauth/challenge`. Presenting a valid signature under a different `client_id` fails with `invalid_grant` and immediately burns the challenge.
3. **Constant-Time HMAC Verification**: Symmetric challenge responses (`HMAC-SHA256`, `HMAC-SHA384`, `HMAC-SHA512`) are compared using `Crypto::Subtle.constant_time_compare` so attackers cannot infer digest bytes via response timing.
4. **Immediate Burn on Attempt**: With the default `config.challenge_max_attempts = 1`, the challenge is removed from `ChallengeStore` on the very first verification attempt—whether the signature is valid or invalid.

---

## 3. Algorithm Confusion & Key Separation

- **JOSE Header `alg` Is Never Trusted**: When decoding JWTs, Authly verifies signatures exclusively against `Authly.config.algorithm`. Tokens carrying `"alg": "none"` or attempting RSA-to-HMAC public-key confusion are rejected.
- **Public Key Validation in JWKS**: `Authly::JWKS` parses DER SubjectPublicKeyInfo structures and verifies that the public key type and curve match `config.algorithm` (`RS*` requires RSA; `ES256`/`ES384`/`ES512` require P-256/P-384/P-521). Private keys are refused outright so a misconfigured `public_key` can never leak private key material at `/.well-known/jwks.json`.

---

## 4. Fail-Closed Rate Limiting & Bounded In-Memory Stores

Endpoints reachable before authentication or with short user codes (`/oauth/device/code`, `/device`, `/oauth/challenge`, `/oauth/register`) are targets for resource exhaustion and brute-force attacks:
- **Bounded In-Memory Stores**: `InMemoryStateStore`, `InMemoryDeviceCodeStore`, `InMemoryChallengeStore`, and `InMemoryAttemptLimiter` enforce strict `MAX_ENTRIES` caps and sweep expired entries periodically during writes.
- **Fail-Closed Rate Limiter**: `InMemoryAttemptLimiter` never evicts a live rate-limit counter to make room for new keys (which would allow an attacker to reset their own counter by flooding the limiter from spoofed keys). If all `MAX_ENTRIES` slots are occupied by live counters, new keys return `Int32::MAX` (`FULL`), causing the endpoint to reject requests until existing windows expire.

---

## 5. Multi-Node Deployment Checklist

When running multiple instances of an Authly server behind a load balancer:
- [ ] Configure the exact same `config.secret_key` (and `config.public_key` / `config.verification_keys`) on every node.
- [ ] Replace all in-memory stores (`token_store`, `state_store`, `challenge_store`, `par_store`, `device_code_store`, `client_store`) with shared Redis or database implementations.
- [ ] Replace `attempt_limiter` and `registration_limiter` with a shared atomic Redis `AttemptLimiter`.
- [ ] Configure `config.attempt_source` to read the client IP header overwritten by your reverse proxy (e.g., `X-Real-IP`), and ensure the application is only reachable through that proxy.
64 changes: 64 additions & 0 deletions docs/explanation/token-strategies-and-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
description: Trade-offs between JWT and Opaque tokens, token-type isolation, and refresh token rotation with family replay detection.
icon: arrows-rotate
---

# Token Strategies & Refresh Token Lifecycle

Authly supports two token strategies (`:jwt` and `:opaque`) and implements OAuth 2.1 / Security BCP refresh token rotation with family-level replay detection.

---

## 1. JWT vs. Opaque Access Tokens

{% tabs %}
{% tab title=":jwt Strategy (Default)" %}
- **Self-Contained**: Access tokens and refresh tokens are signed JSON Web Tokens containing `sub`, `iss`, `cid`, `scope`, `jti`, `iat`, `exp`, and optional `aud` and `cnf` claims.
- **Stateless Verification**: Resource servers verify signatures and claims locally without a database lookup, consulting `TokenStore#revoked?(jti)` only for revocation checks.
- **Best For**: Distributed microservices and high-throughput APIs.
{% endtab %}

{% tab title=":opaque Strategy" %}
- **Reference Tokens**: Access and refresh tokens are 256-bit cryptographically random hex strings (`Random::Secure.hex(32)`).
- **Zero Client-Visible Metadata**: Token claims (`cid`, `sub`, `scope`, `exp`, `cnf`) are stored exclusively server-side in `Authly.config.token_store`.
- **Best For**: Environments where token claims must remain confidential from clients or where immediate centralized revocation is required.
{% endtab %}
{% endtabs %}

---

## 2. Strict Separation of Access and Refresh Tokens

Every token issued by Authly carries an explicit `"token_type"` claim (`"access_token"` or `"refresh_token"`):

{% hint style="warning" %}
`Authly::Handler::Bearer`, `Authly.valid?`, and `POST /oauth/introspect` **only accept `"access_token"`**. Presenting a refresh token as a Bearer token on an API route or to `/oauth/introspect` is always rejected, even though both tokens are signed with the same key.
{% endhint %}

---

## 3. Refresh Token Rotation & Family Reuse Detection

```mermaid
sequenceDiagram
participant Client
participant Authly as Authly (/oauth/token)
participant Store as TokenStore

Client->>Authly: Redeem Refresh Token R1 (fid: F1)
Authly->>Store: consume(R1) -> true
Authly-->>Client: Issue Access Token + New Refresh Token R2 (fid: F1)
Note over Client,Authly: Attacker or replayed request presents R1 again
Client->>Authly: Redeem Refresh Token R1 (fid: F1)
Authly->>Store: consume(R1) -> false (Already Consumed!)
Authly->>Store: revoke_family(F1) (Invalidates R2 and entire family)
Authly-->>Client: 400 invalid_grant
```

When a refresh token is redeemed at `POST /oauth/token` (`grant_type=refresh_token`):

1. **Single-Use Consumption**: Authly calls `token_store.consume(jti)` to atomically mark the presented refresh token as used.
2. **Rotation within a Family (`fid`)**: A new access token and a **new** refresh token are issued, carrying the same rotation family ID (`fid`).
3. **Replay / Theft Detection**: If an already-consumed refresh token is presented a second time (indicating either a network replay or that an attacker stole the token and raced the legitimate client), Authly immediately calls `RefreshToken.revoke_family(fid)`. Every refresh token in that family is invalidated, forcing a fresh sign-in.
4. **Absolute Family Lifetime (`fexp`)**: Continuous rotation cannot extend a session indefinitely. Every refresh token family carries an absolute expiration timestamp (`fexp`, controlled by `config.refresh_absolute_ttl = 30.days`). No rotated refresh token can outlive its family's `fexp`.
5. **Scope Narrowing**: A refresh request may omit `scope` (retaining the original scope) or request a narrower subset of scopes. Even when an access token is narrowed, the replacement refresh token preserves the original granted scope so subsequent refreshes can request other subsets of the original grant.
28 changes: 28 additions & 0 deletions docs/how-to/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
description: Practical recipes for configuring Authly in production environments.
icon: wrench
---

# How-To Guides

These guides show you how to solve specific problems in production—such as connecting Authly to PostgreSQL and Redis, enforcing FAPI security profiles, rotating signing keys, and protecting API routes.

{% content-ref url="custom-stores-and-models.md" %}
[custom-stores-and-models.md](custom-stores-and-models.md)
{% endcontent-ref %}

{% content-ref url="protect-resource-apis.md" %}
[protect-resource-apis.md](protect-resource-apis.md)
{% endcontent-ref %}

{% content-ref url="fapi-and-advanced-security.md" %}
[fapi-and-advanced-security.md](fapi-and-advanced-security.md)
{% endcontent-ref %}

{% content-ref url="key-rotation-and-jwks.md" %}
[key-rotation-and-jwks.md](key-rotation-and-jwks.md)
{% endcontent-ref %}

{% content-ref url="dynamic-client-registration.md" %}
[dynamic-client-registration.md](dynamic-client-registration.md)
{% endcontent-ref %}
Loading
Loading