aRustyDev / aRustyDev/mdbook-htmx
docs(adr): ADR-0008: Separate Authentication and Authorization Configuration
- Dominant language
- Rust
- Stars
- 0
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
# ADR-0008: Separate Authentication and Authorization Configuration
## Status
Accepted
## Context
Documentation sites with access control need both:
1. **Authentication (authn)** - Verifying user identity ("Who are you?")
2. **Authorization (authz)** - Checking permissions ("What can you access?")
The original design conflated these into a single `[output.htmx.auth]` section. This ADR addresses why they should be separate.
## Decision Drivers
1. **Public Sign-In Pages** - Login/register pages must be accessible without authentication
2. **Anonymous Users** - Some pages are public; anonymous users should see them
3. **Different Providers** - Authentication and authorization may use different systems
4. **Separation of Concerns** - Identity verification ≠ permission checking
## User Flow
```asciidoc
┌─────────────────────────────────────────────────────────────────────┐
│ User Request │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────┐
│ Is page public? │
│ (authz.default-access) │
└──────────────────────────────┘
│ │
Yes No
│ │
▼ ▼
┌──────────┐ ┌──────────────────────────┐
│ Serve │ │ Is user authenticated? │
│ Page │ │ (authn check) │
└──────────┘ └──────────────────────────┘
│ │
Yes No
│ │
▼ ▼
┌─────────────────┐ ┌────────────────┐
│ Is user │ │ Redirect to │
│ authorized? │ │ signin partial │
│ (authz check) │ │ (authn.signin) │
└─────────────────┘ └────────────────┘
│ │
Yes No
│ │
▼ ▼
┌──────────┐ ┌──────────────────┐
│ Serve │ │ Show access- │
│ Page │ │ denied partial │
└──────────┘ └──────────────────┘
```
## Decision
**Separate `[output.htmx.authn]` and `[output.htmx.authz]` configuration sections.**
### Configuration Schema
```toml
# Authentication configuration
[output.htmx.authn]
enabled = true
signin-page = "/auth/signin" # Public sign-in page path
signin-partial = "partials/signin.html"
signout-page = "/auth/signout"
session-header = "X-User-Session" # Header containing session token
user-header = "X-User-ID" # Header containing user identity
# Provider hint (for documentation/reference implementation)
provider = "jwt" # jwt | session | oauth2 | saml
# Authorization configuration
[output.htmx.authz]
enabled = true
frontmatter-key = "auth" # Frontmatter key for authz rules
default-access = "public" # public | authenticated | roles
denied-partial = "partials/access-denied.html"
# Provider hint (for documentation/reference implementation)
provider = "manifest" # manifest | spicedb | openfga | opa
```
### Frontmatter Schema
Pages declare authorization requirements, not authentication:
```yaml
---
title: Admin Settings
auth:
access: roles # public | authenticated | roles
roles: [admin, editor] # Required roles (if access == roles)
fallback: /docs/access-denied
---
```
### Manifest Schema
```json
{
"authn": {
"signin": "/auth/signin",
"signout": "/auth/signout"
},
"pages": [
{
"path": "/docs/admin",
"auth": {
"access": "roles",
"roles": ["admin"]
}
},
{
"path": "/auth/signin",
"auth": {
"access": "public"
}
}
]
}
```
## Implementation Pattern
### Server Middleware
```typescript
// Separate middleware for authn and authz
// 1. Authentication middleware (runs first)
app.use("*", async (c, next) => {
const session = c.req.header("X-User-Session");
if (session) {
const user = await validateSession(session);
c.set("user", user);
}
// Don't block - let authz decide if user is required
await next();
});
// 2. Authorization middleware
app.use("/docs/*", async (c, next) => {
const page = manifest.pages.find(p => p.path === c.req.path);
const user = c.get("user");
// Public pages - allow everyone
if (page.auth.access === "public") {
return next();
}
// Authenticated pages - require any logged-in user
if (page.auth.access === "authenticated") {
if (!user) {
return redirectToSignin(c, page);
}
return next();
}
// Role-based pages - require specific roles
if (page.auth.access === "roles") {
if (!user) {
return redirectToSignin(c, page);
}
if (!hasRequiredRoles(user, page.auth.roles)) {
return showAccessDenied(c, page);
}
return next();
}
});
function redirectToSignin(c: Context, page: Page) {
const returnUrl = encodeURIComponent(c.req.path);
if (isHtmxRequest(c.req)) {
// Return signin partial with OOB swap
return c.html(`
${signinPartial}
`);
}
return c.redirect(`${manifest.authn.signin}?return=${returnUrl}`);
}
```
### Sign-In Partial
```html
Sign in required
This page requires authentication.
Sign In
Sign In
```
## Why Two Sections?
| Concern | authn | authz |
|---------|-------|-------|
| **Question** | Who is this user? | Can this user access this page? |
| **When** | Every request (optionally) | Only for protected pages |
| **Provider** | IdP (Okta, Auth0, AD) | Policy engine or manifest |
| **Failure** | Redirect to sign-in | Show access-denied |
| **Pages affected** | Sign-in/out pages special | All pages have access rules |
## Consequences
### Positive
- Clear separation of identity vs. permission
- Sign-in pages naturally public without special cases
- Different providers for authn (Okta) and authz (OpenFGA)
- Anonymous browsing supported (public pages work)
- Composable middleware
### Negative
- Two configuration sections instead of one
- Must coordinate authn and authz providers
- More complex mental model
### Mitigation
- Document the flow clearly (see diagram above)
- Provide reference implementations
- Sensible defaults (`authn.enabled = false`, `authz.default-access = "public"`)
## References
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
- [OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
- [NIST Digital Identity Guidelines](https://pages.nist.gov/800-63-3/)
Contributor guide
Assessment
This issue has not been assessed yet.