feat(server): door-surface Basic auth tolerance (review 008)
review 008 resolution: - AH-1: door_auth_middleware on extras doors resolves Authorization: Basic base64(user:pass) — password field as the token (GitHub-PAT shape, git-lfs / git smart-HTTP clients) — through the same IdentityProvider::resolve_from_token backend as Bearer; no new trait method, resolve-fails -> None fallback preserved. Bearer surfaces stay Bearer-only: the gateway endpoints, /openapi.json, /healthz, the MCP nest, and the WS upgrade path never resolve Basic. The 'Bearer-only is the auth mechanism' doc line flips (ADR-073). - AH-2 ride-along: extras door route tables are static — the merged router owns the decoy fallback, a second fallback panics at construction; documented on with_extra_routes and ADR-046 §1. docs: ADR-073, ADR-046 §1/§4 amendment, ADR-004 presentation table, review 008 marked resolved; new server::* re-exports; dep:base64 behind the server feature. Verification: cargo test (449 pass, incl. door Basic resolve-through and gateway-stays-Bearer-only pins), test --all-features (528), clippy --all-targets -D warnings, fmt --check, fuzz corpus replay (21/21), cargo doc.
This commit is contained in:
1 parent
ad63507124
commit
0766f4bebe
12 files changed
+511
-55
No files matched your search
@@ -6,6 +6,25 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [0.8.0] — 2026-10-17
|
## [0.8.0] — 2026-10-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Door-surface Basic auth tolerance (review 008 AH-1, ADR-073).**
|
||||||
|
The extras-door surface now resolves `Authorization: Basic
|
||||||
|
base64(user:pass)` — the password field acts as the token and rides
|
||||||
|
the same `IdentityProvider::resolve_from_token()` backend as Bearer
|
||||||
|
(the GitHub-PAT shape `git-lfs` and git's smart-HTTP transport
|
||||||
|
authenticate with). No new trait method; the provider's grant table
|
||||||
|
governs exactly as it does for Bearer, and "unparseable /
|
||||||
|
unresolvable → None" fallback semantics are preserved. Basic is
|
||||||
|
scoped to the door surface: `bearer_auth_middleware` stays
|
||||||
|
Bearer-only on the gateway endpoints, `/openapi.json`, `/healthz`,
|
||||||
|
and the MCP nest, and the WS upgrade path is untouched — a Basic
|
||||||
|
credential contributes no identity on any path a deployment has not
|
||||||
|
explicitly mounted doors on. New public API:
|
||||||
|
`server::door_auth_middleware`, `server::extract_door_identity`
|
||||||
|
(`bearer_auth_middleware` / `extract_bearer_identity` keep their
|
||||||
|
Bearer-only semantics and their coverage).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **`alkcall` dependency bumped to 0.10.0** (the gateway ownership-seam
|
- **`alkcall` dependency bumped to 0.10.0** (the gateway ownership-seam
|
||||||
@@ -25,6 +44,16 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
|
|||||||
static fallback) are pinned at the HTTP surface by
|
static fallback) are pinned at the HTTP surface by
|
||||||
`call_resource_gated_*` regression tests.
|
`call_resource_gated_*` regression tests.
|
||||||
|
|
||||||
|
### Documented
|
||||||
|
|
||||||
|
- **Extras door route tables are static (review 008 AH-2).** One doc
|
||||||
|
line on the door-facing surface (`HttpAdapter::with_extra_routes`)
|
||||||
|
and in ADR-046: the merged router already owns the decoy fallback
|
||||||
|
and axum refuses a second, so a door attempting `fallback_layer`
|
||||||
|
panics at construction — enumerate multi-segment namespace shapes
|
||||||
|
(`{user}/{repo}/...`) as pattern routes. Not a mechanism ask; the
|
||||||
|
next door author should not rediscover the panic.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- `HttpAdapter::with_ws_max_sessions` never stored the freshly built
|
- `HttpAdapter::with_ws_max_sessions` never stored the freshly built
|
||||||
|
|||||||
Generated
+1
@@ -54,6 +54,7 @@ dependencies = [
|
|||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
"http",
|
"http",
|
||||||
|
|||||||
+2
-1
@@ -25,7 +25,7 @@ default = ["server", "client", "h2", "http1"]
|
|||||||
# driver. `openapi` rides along because /openapi.json is part of the
|
# driver. `openapi` rides along because /openapi.json is part of the
|
||||||
# default surface (ADR-042/045); `http1` rides along so the hyper
|
# default surface (ADR-042/045); `http1` rides along so the hyper
|
||||||
# auto-builder always has at least one protocol compiled.
|
# auto-builder always has at least one protocol compiled.
|
||||||
server = ["dep:axum", "dep:hyper", "dep:hyper-util", "dep:uuid", "openapi", "http1"]
|
server = ["dep:axum", "dep:base64", "dep:hyper", "dep:hyper-util", "dep:uuid", "openapi", "http1"]
|
||||||
# Consumer side: the shared outbound client host and the HTTP-backed
|
# Consumer side: the shared outbound client host and the HTTP-backed
|
||||||
# import adapters (`from_jsonschema`, `from_openapi` with `openapi`,
|
# import adapters (`from_jsonschema`, `from_openapi` with `openapi`,
|
||||||
# `from_mcp` with `mcp`).
|
# `from_mcp` with `mcp`).
|
||||||
@@ -49,6 +49,7 @@ http1 = ["server", "dep:hyper", "hyper-util/http1", "hyper/http1"]
|
|||||||
alkcall = { version = "0.10", features = ["gateway"] }
|
alkcall = { version = "0.10", features = ["gateway"] }
|
||||||
arc-swap = { version = "1", optional = true }
|
arc-swap = { version = "1", optional = true }
|
||||||
axum = { version = "0.8", optional = true, features = ["ws"] }
|
axum = { version = "0.8", optional = true, features = ["ws"] }
|
||||||
|
base64 = { version = "0.22", optional = true }
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
http = "1"
|
http = "1"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ types — the former `alknet-core` and `alknet-call` merged).
|
|||||||
| [070](decisions/070-from-wss-consumer-adapter.md) | `from_wss` Consumer Adapter | Import a remote node's operations over WSS — same-protocol importer, channels-over-WS as transport; `wss` feature gate |
|
| [070](decisions/070-from-wss-consumer-adapter.md) | `from_wss` Consumer Adapter | Import a remote node's operations over WSS — same-protocol importer, channels-over-WS as transport; `wss` feature gate |
|
||||||
| [071](decisions/071-dispatch-schema-guard.md) | Dispatch-Spine `services/schema` Op-Path Guard | `GatewayDispatch` applies the GET `/schema` visibility+ACL checks to the meta-op's inner `name` (review-002 PRJ-16); alkcall CF-004 is the complete fix, this stays as defense-in-depth (now via the promoted `alkcall::gateway` shared check — spine + guard themselves live in alkcall per ADR-048) |
|
| [071](decisions/071-dispatch-schema-guard.md) | Dispatch-Spine `services/schema` Op-Path Guard | `GatewayDispatch` applies the GET `/schema` visibility+ACL checks to the meta-op's inner `name` (review-002 PRJ-16); alkcall CF-004 is the complete fix, this stays as defense-in-depth (now via the promoted `alkcall::gateway` shared check — spine + guard themselves live in alkcall per ADR-048) |
|
||||||
| [072](decisions/072-http-surface-ownership-wiring.md) | HTTP-Surface Ownership Wiring (Gateway + MCP) | alkcall 0.10's loud ownership default passes through: `HttpAdapter::with_ownership_provider` / `with_static_ownership_fallback` thread the provider into the gateway and MCP spines (single wiring in `GatewayState::dispatch`) |
|
| [072](decisions/072-http-surface-ownership-wiring.md) | HTTP-Surface Ownership Wiring (Gateway + MCP) | alkcall 0.10's loud ownership default passes through: `HttpAdapter::with_ownership_provider` / `with_static_ownership_fallback` thread the provider into the gateway and MCP spines (single wiring in `GatewayState::dispatch`) |
|
||||||
|
| [073](decisions/073-door-surface-basic-auth-tolerance.md) | Door-Surface Basic Auth Tolerance | `door_auth_middleware` on extras doors resolves `Basic base64(user:pass)` (password field as the token) through the same `resolve_from_token` backend; gateway/MCP/WS stay Bearer-only (review 008 AH-1) |
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Credential presentation per handler:
|
|||||||
|---------|------------------------|-------------|
|
|---------|------------------------|-------------|
|
||||||
| SshAdapter | SSH public key handshake | `resolve_from_fingerprint()` |
|
| SshAdapter | SSH public key handshake | `resolve_from_fingerprint()` |
|
||||||
| CallAdapter | AuthToken in first frame | `resolve_from_token()` |
|
| CallAdapter | AuthToken in first frame | `resolve_from_token()` |
|
||||||
| HttpAdapter | `Authorization: Bearer` header | `resolve_from_token()` |
|
| HttpAdapter | `Authorization` header — Bearer (all HTTP surfaces); Basic additionally on the extras-door surface (ADR-073, the password field as the token) | `resolve_from_token()` |
|
||||||
| DnsAdapter | AuthToken in query labels | `resolve_from_token()` |
|
| DnsAdapter | AuthToken in query labels | `resolve_from_token()` |
|
||||||
| WebTransportAdapter | AuthToken in CONNECT headers | `resolve_from_token()` |
|
| WebTransportAdapter | AuthToken in CONNECT headers | `resolve_from_token()` |
|
||||||
| GitAdapter | Signed push certificate | `resolve_from_fingerprint()` |
|
| GitAdapter | Signed push certificate | `resolve_from_fingerprint()` |
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ the one-way constraint is that the assembly layer can inject routes
|
|||||||
and they coexist with the default surface. axum's `Router::merge` /
|
and they coexist with the default surface. axum's `Router::merge` /
|
||||||
`Router::nest` are the natural primitives.
|
`Router::nest` are the natural primitives.
|
||||||
|
|
||||||
|
**Route tables are static (review 008 AH-2).** The merged router
|
||||||
|
already owns the fallback route (the decoy) — axum refuses a second
|
||||||
|
fallback, and a door attempting `fallback_layer`/`fallback` on the
|
||||||
|
merged router panics at construction, not at request time. Door route
|
||||||
|
tables must therefore be statically enumerated: multi-segment
|
||||||
|
repository namespaces (`/{user}/{repo}/objects/batch` shape) are
|
||||||
|
pattern routes with wildcard segments, never a dynamic fallback match.
|
||||||
|
This is a recorded constraint of the router assembly, not a mechanism
|
||||||
|
ask.
|
||||||
|
|
||||||
### 2. Custom routes are raw HTTP, not call-protocol operations
|
### 2. Custom routes are raw HTTP, not call-protocol operations
|
||||||
|
|
||||||
A custom route is a raw axum handler — it receives an HTTP request and
|
A custom route is a raw axum handler — it receives an HTTP request and
|
||||||
@@ -134,16 +144,21 @@ is natural (`/v1/...` doesn't collide).
|
|||||||
|
|
||||||
### 4. Custom routes carry the same auth middleware by default; per-route opt-out is the deployment's choice
|
### 4. Custom routes carry the same auth middleware by default; per-route opt-out is the deployment's choice
|
||||||
|
|
||||||
Custom routes run under the same Bearer-auth resolution as the default
|
Custom routes run under the door-surface auth middleware: Bearer
|
||||||
surface (the `Authorization: Bearer` → `resolve_from_token` path). A
|
resolution identically to the default surface
|
||||||
deployment that wants a custom route to be unauthenticated (a public
|
(`Authorization: Bearer` → `resolve_from_token`), plus — ADR-073 —
|
||||||
webhook receiver, a health endpoint with a different shape than
|
Basic tolerance (`Basic base64(user:pass)`; the password field rides
|
||||||
`/healthz`) applies axum middleware to opt that route out of auth —
|
the same `resolve_from_token` backend). The default token-auth surface
|
||||||
the deployment owns its custom routes' middleware stack. The
|
(gateway endpoints, `/openapi.json`, `/healthz`, MCP) stays
|
||||||
`HttpAdapter` provides the identity provider and the default auth
|
Bearer-only: Basic resolution is scoped to the door surface
|
||||||
middleware; the custom `Router` the assembly layer passes in can
|
specifically constructed for legacy clients. A deployment that wants a
|
||||||
layer its own middleware on top. This is standard axum composition; no
|
custom route to be unauthenticated (a public webhook receiver, a
|
||||||
alknet-specific mechanism.
|
health endpoint with a different shape than `/healthz`) applies axum
|
||||||
|
middleware to opt that route out of auth — the deployment owns its
|
||||||
|
custom routes' middleware stack. The `HttpAdapter` provides the
|
||||||
|
identity provider and the default middlewares; the custom `Router` the
|
||||||
|
assembly layer passes in can layer its own middleware on top. This is
|
||||||
|
standard axum composition; no alknet-specific mechanism.
|
||||||
|
|
||||||
### 5. Custom routes are not part of the published `to_openapi` doc
|
### 5. Custom routes are not part of the published `to_openapi` doc
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# ADR-073: Door-Surface Basic Auth Tolerance (git / git-lfs Clients)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Review 008 AH-1, from alkgit's LFS POC (POC-L): a real `git-lfs`
|
||||||
|
3.7.0 client authenticates the batch endpoint with **Basic**
|
||||||
|
credentials (URL userinfo — the spec-shape behavior of the official
|
||||||
|
client; config `http://user:pass@host/repo`). The shared identity
|
||||||
|
middleware was Bearer-only — the module doc said so explicitly
|
||||||
|
("Bearer-only is the auth mechanism") — so a door mounted under the
|
||||||
|
shared auth layer received the batch request with the middleware
|
||||||
|
already having folded in `None`, and the door had to re-parse the
|
||||||
|
`Authorization` header itself (Basic *or* Bearer) to get any identity.
|
||||||
|
|
||||||
|
The middleware is shared by the gateway endpoints and `to_mcp`. Every
|
||||||
|
HTTP-door surface whose real client authenticates with Basic (LFS
|
||||||
|
today; git's smart-HTTP transport is tomorrow's `git` door) would
|
||||||
|
silently re-implement the fallback locally — the quiet-bypass shape
|
||||||
|
alkcall review 012 records for the ownership seam, one layer up in the
|
||||||
|
same stack.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Split the auth surface into token surfaces and the door surface,
|
||||||
|
and teach the door surface Basic — through the same resolution
|
||||||
|
backend, with no new `IdentityProvider` method.**
|
||||||
|
|
||||||
|
- `bearer_auth_middleware` stays Bearer-only and continues to cover
|
||||||
|
the token-auth surfaces: the gateway endpoints, `/openapi.json`,
|
||||||
|
`/healthz`, and the `to_mcp` nest. A Basic header contributes no
|
||||||
|
identity there. `/call`, `/batch`, `/subscribe`, `/publish`, `/mcp`,
|
||||||
|
and the WS upgrade path are token surfaces — this is the "no blanket
|
||||||
|
method" constraint: a Basic credential never gains identity anywhere
|
||||||
|
the deployment has not explicitly set up for it.
|
||||||
|
- `door_auth_middleware` (new) is applied by `build_router` to the
|
||||||
|
extras-door router (`HttpAdapter::with_extra_routes`) — the surface
|
||||||
|
legacy clients authenticate on.
|
||||||
|
- Both middlewares resolve through `IdentityProvider::resolve_from_token()`.
|
||||||
|
Basic parsing: `Basic base64(user:pass)` — the password field (the
|
||||||
|
GitHub-PAT shape; the user segment is ignored) is decoded and fed as
|
||||||
|
the `AuthToken` raw bytes. A colon-less value is treated as a bare
|
||||||
|
password. No new trait method: the provider's grant table governs a
|
||||||
|
Basic credential exactly as it governs the same credential presented
|
||||||
|
as Bearer — and the same backend mechanism that grants privileges to
|
||||||
|
tokens grants them to these credentials (SOCKS5
|
||||||
|
userinfo-style integrations can follow the same pattern later).
|
||||||
|
- Fallback semantics unchanged: no header → `None`; unparseable or
|
||||||
|
unresolvable credential → `None` (unauthenticated, not an error).
|
||||||
|
A parse-time failure never becomes a 4xx of its own.
|
||||||
|
- Doors may opt out with their own middleware stack as before
|
||||||
|
([ADR-046](046-assembly-layer-custom-http-routes.md) §4).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
|
||||||
|
- `git-lfs` / `git` doors work against a deployment whose provider
|
||||||
|
maps password-tokens through `resolve_from_token`, with zero
|
||||||
|
door-side header re-parsing.
|
||||||
|
- Scoping is structural (per-middleware), not
|
||||||
|
per-path-inspection: Basic tolerance exists only where the
|
||||||
|
assembly layer mounted the door surface.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
|
||||||
|
- A door surface deployment that does *not* want its door credentials
|
||||||
|
resolvable as Basic must now ensure the provider does not resolve
|
||||||
|
those values as tokens — or opt the door out of the shared layer.
|
||||||
|
- Scheme prefixes remain case-sensitive ("Bearer "/"Basic "), matching
|
||||||
|
the pre-existing Bearer parsing; strictly RFC-noncompliant
|
||||||
|
lowercase-scheme clients parse to `None`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Review 008](../../reviews/008-http-door-consumer-review.md) — AH-1
|
||||||
|
evidence and disposition space
|
||||||
|
- [ADR-004](004-auth-as-shared-core.md) — the shared-core resolution
|
||||||
|
backend this rides
|
||||||
|
- [ADR-046](046-assembly-layer-custom-http-routes.md) — the extras-door
|
||||||
|
surface the door middleware covers (§4 amended; AH-2 static-route
|
||||||
|
constraint recorded there)
|
||||||
|
- alkcall review 012 — the same-round quiet-bypass analysis this
|
||||||
|
avoids repeating at the door layer
|
||||||
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Open — filed 2026-10-17. Report-only consumer input from alkgit's LFS
|
Resolved — 2026-10-17. AH-1: door-surface Basic tolerance landed as
|
||||||
|
[ADR-073](../architecture/decisions/073-door-surface-basic-auth-tolerance.md)
|
||||||
|
(`door_auth_middleware` on extras doors; password field through the
|
||||||
|
same `resolve_from_token` backend; token surfaces stay Bearer-only).
|
||||||
|
AH-2: recorded on the door-facing surface
|
||||||
|
([ADR-046](../architecture/decisions/046-assembly-layer-custom-http-routes.md)
|
||||||
|
§1). Both ride the alkhttp 0.8.0 release (with the alkcall 0.10
|
||||||
|
ownership wiring, ADR-072). Filed 2026-10-17 as consumer input from
|
||||||
|
alkgit's LFS
|
||||||
POC (POC-L, 2026-10-16; findings at
|
POC (POC-L, 2026-10-16; findings at
|
||||||
`/workspace/@alkdev/alkgit/docs/research/pocl-findings.md`; the POC
|
`/workspace/@alkdev/alkgit/docs/research/pocl-findings.md`; the POC
|
||||||
crate is `/workspace/lfs-poc`, which served a real `git-lfs` 3.7.0
|
crate is `/workspace/lfs-poc`, which served a real `git-lfs` 3.7.0
|
||||||
|
|||||||
Generated
+1
@@ -54,6 +54,7 @@ dependencies = [
|
|||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
"http",
|
"http",
|
||||||
|
|||||||
+156
-26
@@ -48,7 +48,7 @@ use hyper_util::service::TowerToHyperService;
|
|||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use super::auth::bearer_auth_middleware;
|
use super::auth::{bearer_auth_middleware, door_auth_middleware};
|
||||||
use super::decoy::{decoy_fallback, decoy_method_not_allowed};
|
use super::decoy::{decoy_fallback, decoy_method_not_allowed};
|
||||||
use super::healthz::healthz;
|
use super::healthz::healthz;
|
||||||
use super::state::{DecoyConfig, RouterState};
|
use super::state::{DecoyConfig, RouterState};
|
||||||
@@ -173,9 +173,22 @@ impl HttpAdapter {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mount assembly-provided custom routes under the bearer-auth
|
/// Mount assembly-provided custom routes under the door-surface
|
||||||
/// middleware (ADR-046) and rebuild the router. Reserved paths
|
/// auth middleware (ADR-046, ADR-073) and rebuild the router.
|
||||||
/// (`RESERVED_PATHS`) are rejected before the merge.
|
/// Reserved paths (`RESERVED_PATHS`) are rejected before the merge.
|
||||||
|
///
|
||||||
|
/// The door middleware resolves Bearer and — ADR-073, review 008
|
||||||
|
/// AH-1 — `Basic base64(user:pass)` (the password field through the
|
||||||
|
/// same `IdentityProvider::resolve_from_token()` backend), the
|
||||||
|
/// surface legacy clients (`git-lfs`, git's smart-HTTP transport)
|
||||||
|
/// authenticate on. The token-auth surface this merges onto stays
|
||||||
|
/// Bearer-only.
|
||||||
|
///
|
||||||
|
/// Door route tables are static (review 008 AH-2): the merged
|
||||||
|
/// router already owns the decoy fallback and axum refuses a
|
||||||
|
/// second one, so a door that attempts `fallback_layer`/`fallback`
|
||||||
|
/// panics at construction — enumerate multi-segment namespace
|
||||||
|
/// shapes ({user}/{repo}/... style) as pattern routes instead.
|
||||||
pub fn with_extra_routes(mut self, routes: Router) -> Self {
|
pub fn with_extra_routes(mut self, routes: Router) -> Self {
|
||||||
// `extra_routes` is borrowed, not consumed (SRV-05): a builder
|
// `extra_routes` is borrowed, not consumed (SRV-05): a builder
|
||||||
// call after `with_extra_routes` must keep the custom routes in
|
// call after `with_extra_routes` must keep the custom routes in
|
||||||
@@ -388,13 +401,29 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
|||||||
// cache (SRV-09), threaded through `RouterState` below.
|
// cache (SRV-09), threaded through `RouterState` below.
|
||||||
.route("/openapi.json", get(openapi_json_handler))
|
.route("/openapi.json", get(openapi_json_handler))
|
||||||
.route("/healthz", get(healthz))
|
.route("/healthz", get(healthz))
|
||||||
|
// The token-auth surface's bearer layer, applied before the
|
||||||
|
// extras merge: routes registered before this call — the
|
||||||
|
// gateway endpoints, /openapi.json, /healthz — resolve
|
||||||
|
// Bearer-only (ADR-073: a Basic header contributes no identity
|
||||||
|
// on these paths; Basic is scoped to the door surface below).
|
||||||
|
.route_layer(from_fn_with_state(
|
||||||
|
Arc::clone(&auth_state),
|
||||||
|
bearer_auth_middleware,
|
||||||
|
))
|
||||||
.fallback(decoy_fallback)
|
.fallback(decoy_fallback)
|
||||||
.method_not_allowed_fallback(decoy_method_not_allowed);
|
.method_not_allowed_fallback(decoy_method_not_allowed);
|
||||||
|
|
||||||
let with_extras = match extra_routes {
|
let with_extras = match extra_routes {
|
||||||
Some(extra) => {
|
Some(extra) => {
|
||||||
enforce_reserved_paths(&extra);
|
enforce_reserved_paths(&extra);
|
||||||
let extra: Router<RouterState> = extra.with_state(());
|
// The door surface's auth layer (ADR-073): Basic-tolerant
|
||||||
|
// in addition to Bearer — exactly one resolution per door
|
||||||
|
// request (SRV-11), and the legacy-client surface this is
|
||||||
|
// scoped to.
|
||||||
|
let extra: Router<RouterState> = extra.with_state(()).route_layer(from_fn_with_state(
|
||||||
|
Arc::clone(&auth_state),
|
||||||
|
door_auth_middleware,
|
||||||
|
));
|
||||||
default.merge(extra)
|
default.merge(extra)
|
||||||
}
|
}
|
||||||
None => default,
|
None => default,
|
||||||
@@ -407,28 +436,13 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
|||||||
// surface. Idempotent for the routers the earlier call covered.
|
// surface. Idempotent for the routers the earlier call covered.
|
||||||
let with_extras = with_extras.method_not_allowed_fallback(decoy_method_not_allowed);
|
let with_extras = with_extras.method_not_allowed_fallback(decoy_method_not_allowed);
|
||||||
|
|
||||||
// Applied after the merges (ADR-046 §4): the bearer-auth layer wraps
|
// Merged after the default surface's route_layer: the /mcp nest
|
||||||
// every route registered before this call (the gateway endpoints,
|
// carries its own bearer layer (applied around the nested service,
|
||||||
// /openapi.json, /healthz, and the extra routes) — axum 0.8
|
// `from_fn_with_state` above), so registering it here keeps exactly
|
||||||
// semantics are that `route_layer` wraps the routes registered
|
// one token resolution per request (SRV-11). nest_service
|
||||||
// before it, not the ones after. Routes that must not pass through
|
|
||||||
// this layer (the /mcp nest and the WS upgrade route, each with its
|
|
||||||
// own auth) are merged/registered after it — SRV-11: before the
|
|
||||||
// reorder, a route carrying an inner auth layer was also wrapped by
|
|
||||||
// this one and resolved the token twice (the SRV-10 double-resolve,
|
|
||||||
// the old comment claiming the opposite).
|
|
||||||
let with_auth = with_extras.route_layer(from_fn_with_state(
|
|
||||||
Arc::clone(&auth_state),
|
|
||||||
bearer_auth_middleware,
|
|
||||||
));
|
|
||||||
|
|
||||||
// Merged after the router-wide route_layer on purpose: the /mcp
|
|
||||||
// nest carries its own bearer layer (applied around the nested
|
|
||||||
// service, `from_fn_with_state` above), so registering it here keeps
|
|
||||||
// exactly one token resolution per request (SRV-11). nest_service
|
|
||||||
// registers a plain Route endpoint (no MethodRouter), so the decoy
|
// registers a plain Route endpoint (no MethodRouter), so the decoy
|
||||||
// 405 fallback has no interplay with this merge.
|
// 405 fallback has no interplay with this merge.
|
||||||
let with_mcp = with_auth.merge(mcp_router);
|
let with_mcp = with_extras.merge(mcp_router);
|
||||||
|
|
||||||
// Registered after the router-wide route_layer on purpose: axum's
|
// Registered after the router-wide route_layer on purpose: axum's
|
||||||
// route_layer applies only to earlier-registered routes, so the WS
|
// route_layer applies only to earlier-registered routes, so the WS
|
||||||
@@ -762,7 +776,7 @@ async fn openapi_json_handler(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::server::auth::ResolvedIdentity;
|
use crate::server::auth::ResolvedIdentity;
|
||||||
use alkcall::core::auth::IdentityProvider;
|
use alkcall::core::auth::{Identity, IdentityProvider};
|
||||||
use alkcall::core::types::ProtocolHandler;
|
use alkcall::core::types::ProtocolHandler;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
@@ -1298,6 +1312,122 @@ mod tests {
|
|||||||
assert_eq!(&body[..], b"none");
|
assert_eq!(&body[..], b"none");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn extra_doors_resolve_basic_credentials_but_the_gateway_stays_bearer_only() {
|
||||||
|
struct PasswordProvider;
|
||||||
|
impl IdentityProvider for PasswordProvider {
|
||||||
|
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn resolve_from_token(
|
||||||
|
&self,
|
||||||
|
token: &alkcall::core::auth::AuthToken,
|
||||||
|
) -> Option<Identity> {
|
||||||
|
(String::from_utf8_lossy(&token.raw) == "lfs-token").then(|| Identity {
|
||||||
|
id: "lfs-client".to_string(),
|
||||||
|
scopes: vec![],
|
||||||
|
resources: std::collections::HashMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let idp: Arc<dyn IdentityProvider> = Arc::new(PasswordProvider);
|
||||||
|
let extra = Router::new().route(
|
||||||
|
"/v1/whoami",
|
||||||
|
get(|ResolvedIdentity(identity): ResolvedIdentity| async move {
|
||||||
|
match identity {
|
||||||
|
Some(id) => id.id,
|
||||||
|
None => "none".to_string(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let app = build_router(router_state(Arc::clone(&idp)), Some(extra));
|
||||||
|
|
||||||
|
let response = get_with_bearer(
|
||||||
|
app.clone(),
|
||||||
|
"/v1/whoami",
|
||||||
|
Some("Basic dXNlcjpsZnMtdG9rZW4="),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), axum::http::StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
&body[..],
|
||||||
|
b"lfs-client",
|
||||||
|
"a Basic password resolves on the door surface"
|
||||||
|
);
|
||||||
|
|
||||||
|
let registry = OperationRegistry::new();
|
||||||
|
registry
|
||||||
|
.register(alkcall::registry::registration::HandlerRegistration::new(
|
||||||
|
alkcall::registry::spec::OperationSpec::new(
|
||||||
|
"echo/run",
|
||||||
|
alkcall::registry::spec::OperationType::Query,
|
||||||
|
alkcall::registry::spec::Visibility::External,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
alkcall::registry::spec::AccessControl {
|
||||||
|
required_scopes: vec!["admin".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
alkcall::registry::registration::HandlerKind::Once(
|
||||||
|
alkcall::registry::registration::make_handler(|input, ctx| async move {
|
||||||
|
alkcall::protocol::wire::ResponseEnvelope::ok(ctx.request_id, input)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
alkcall::registry::registration::OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
alkcall::core::types::Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let gateway_state = RouterState {
|
||||||
|
registry: Arc::new(registry),
|
||||||
|
identity_provider: Arc::clone(&idp),
|
||||||
|
ownership_provider: None,
|
||||||
|
static_ownership_fallback: false,
|
||||||
|
decoy: DecoyConfig::default(),
|
||||||
|
openapi_doc: CachedOpenAPIDoc::new(&OperationRegistry::new()),
|
||||||
|
ws_sessions: Arc::new(crate::websocket::WsSessions::new()),
|
||||||
|
ws_session_slots: Arc::new(tokio::sync::Semaphore::new(
|
||||||
|
crate::websocket::DEFAULT_WS_MAX_SESSIONS,
|
||||||
|
)),
|
||||||
|
ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT),
|
||||||
|
ws_openable_alpns: None,
|
||||||
|
ws_op_register_acl: alkcall::registry::spec::AccessControl::default(),
|
||||||
|
};
|
||||||
|
let app = build_router(gateway_state, None);
|
||||||
|
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/call")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header(
|
||||||
|
axum::http::header::AUTHORIZATION,
|
||||||
|
"Basic dXNlcjpsZnMtdG9rZW4=",
|
||||||
|
)
|
||||||
|
.body(axum::body::Body::from(
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"operation": "echo/run",
|
||||||
|
"input": {},
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let response = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
"a Basic credential contributes no identity on the token-auth gateway surface"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn extra_route_with_own_layer_can_opt_out_of_default_auth() {
|
async fn extra_route_with_own_layer_can_opt_out_of_default_auth() {
|
||||||
async fn public_identity(
|
async fn public_identity(
|
||||||
|
|||||||
+195
-15
@@ -1,25 +1,42 @@
|
|||||||
//! Shared Bearer auth axum middleware ([ADR-004]).
|
//! Shared identity-auth axum middlewares ([ADR-004], [ADR-073]).
|
||||||
//!
|
//!
|
||||||
//! Resolves the `Authorization: Bearer` header via
|
//! Two middleware shapes, one resolution backend
|
||||||
//! `IdentityProvider::resolve_from_token()` and stashes the resolved
|
//! (`IdentityProvider::resolve_from_token()`), both stashing the
|
||||||
//! `Option<Identity>` in request extensions. Shared by the HTTP gateway
|
//! resolved `Option<Identity>` in request extensions:
|
||||||
//! endpoints and the `to_mcp` service.
|
|
||||||
//!
|
//!
|
||||||
//! Resolution semantics:
|
//! - **[`bearer_auth_middleware`]** — Bearer-only. Applied to the
|
||||||
|
//! gateway endpoints, `/openapi.json`, `/healthz`, and the `to_mcp`
|
||||||
|
//! service. Token-auth surfaces (the gateway is the sole HTTP invoke
|
||||||
|
//! path; MCP's core protocol is token-based) never resolve credentials
|
||||||
|
//! presented as Basic.
|
||||||
|
//! - **[`door_auth_middleware`]** — Basic-tolerant (ADR-073). Applied to
|
||||||
|
//! the extras-door router (`HttpAdapter::with_extra_routes`), the
|
||||||
|
//! surface legacy clients authenticate on: `git-lfs`'s batch API and
|
||||||
|
//! git's smart-HTTP transport send `Basic base64(user:pass)`, with
|
||||||
|
//! the password field acting as the token (the GitHub-PAT shape). The
|
||||||
|
//! password bytes ride the same
|
||||||
|
//! `IdentityProvider::resolve_from_token()` backend as a Bearer
|
||||||
|
//! token — no separate resolution mechanism, no new trait method;
|
||||||
|
//! the provider's grant table governs exactly as it does for Bearer.
|
||||||
|
//! Basic resolution is deliberately scoped to the door surface: a
|
||||||
|
//! Basic credential is never resolved on the bearer-auth surfaces
|
||||||
|
//! unless the deployment explicitly mounts it there.
|
||||||
|
//!
|
||||||
|
//! Common resolution semantics (both middlewares):
|
||||||
//! - No `Authorization` header → `None` (request proceeds; the route
|
//! - No `Authorization` header → `None` (request proceeds; the route
|
||||||
//! handler / `AccessControl` decides whether to reject).
|
//! handler / `AccessControl` decides whether to reject).
|
||||||
//! - Malformed `Authorization` header (not `Bearer <token>`) → `None`
|
//! - Unrecognized or unparseable `Authorization` header → `None`
|
||||||
//! (treated as no-token, not an error — Bearer-only is the auth
|
//! (treated as no-token, not an error).
|
||||||
//! mechanism).
|
//! - Credential present but resolution fails → `None` (treat as
|
||||||
//! - Token present but resolution fails → `None` (treat as
|
|
||||||
//! unauthenticated, matching the call protocol's per-request identity
|
//! unauthenticated, matching the call protocol's per-request identity
|
||||||
//! resolution behavior).
|
//! resolution behavior).
|
||||||
//!
|
//!
|
||||||
//! This middleware resolves identity and stashes it; it does NOT enforce
|
//! These middlewares resolve identity and stash it; they do NOT enforce
|
||||||
//! `AccessControl` (the route handlers / `GatewayDispatch::invoke()` do)
|
//! `AccessControl` (the route handlers / `GatewayDispatch::invoke()` do)
|
||||||
//! or map `CallError` codes to HTTP status (the error mapping does).
|
//! or map `CallError` codes to HTTP status (the error mapping does).
|
||||||
//!
|
//!
|
||||||
//! [ADR-004]: https://docs.rs/alkhttp (docs/architecture/decisions)
|
//! [ADR-004]: https://docs.rs/alkhttp (docs/architecture/decisions)
|
||||||
|
//! [ADR-073]: https://docs.rs/alkhttp (docs/architecture/decisions)
|
||||||
|
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -31,9 +48,13 @@ use axum::middleware::Next;
|
|||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use http::request::Parts;
|
use http::request::Parts;
|
||||||
|
|
||||||
/// Axum middleware that resolves the `Authorization: Bearer` header via
|
/// Axum middleware for the token-auth surfaces (the gateway endpoints,
|
||||||
|
/// `/openapi.json`, `/healthz`, `to_mcp`): resolves the
|
||||||
|
/// `Authorization: Bearer` header via
|
||||||
/// `IdentityProvider::resolve_from_token()` and stashes the resolved
|
/// `IdentityProvider::resolve_from_token()` and stashes the resolved
|
||||||
/// `Option<Identity>` in request extensions.
|
/// `Option<Identity>` in request extensions. Bearer-only — a Basic
|
||||||
|
/// header contributes no identity (ADR-073 keeps Basic scoped to
|
||||||
|
/// [`door_auth_middleware`]).
|
||||||
///
|
///
|
||||||
/// The state is `Arc<dyn IdentityProvider>` so the middleware can be applied
|
/// The state is `Arc<dyn IdentityProvider>` so the middleware can be applied
|
||||||
/// via `middleware::from_fn_with_state(idp.clone(), bearer_auth_middleware)`
|
/// via `middleware::from_fn_with_state(idp.clone(), bearer_auth_middleware)`
|
||||||
@@ -48,6 +69,26 @@ pub async fn bearer_auth_middleware(
|
|||||||
next.run(request).await
|
next.run(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Axum middleware for the extras-door surface (`HttpAdapter::
|
||||||
|
/// with_extra_routes`, ADR-073): resolves the `Authorization` header —
|
||||||
|
/// Bearer first, then Basic (the password field as the token via the
|
||||||
|
/// same `IdentityProvider::resolve_from_token()` backend) — and stashes
|
||||||
|
/// the resolved `Option<Identity>` in request extensions. This is the
|
||||||
|
/// surface legacy clients (`git-lfs`, git's smart-HTTP transport)
|
||||||
|
/// authenticate on with the Basic scheme.
|
||||||
|
///
|
||||||
|
/// The state is `Arc<dyn IdentityProvider>` (same shape as
|
||||||
|
/// [`bearer_auth_middleware`]).
|
||||||
|
pub async fn door_auth_middleware(
|
||||||
|
State(identity_provider): State<Arc<dyn IdentityProvider>>,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let identity = extract_door_identity(&request, identity_provider.as_ref());
|
||||||
|
request.extensions_mut().insert(identity);
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract the `Authorization: Bearer <token>` header and resolve it to
|
/// Extract the `Authorization: Bearer <token>` header and resolve it to
|
||||||
/// an `Option<Identity>`. Returns `None` if no token is present (the
|
/// an `Option<Identity>`. Returns `None` if no token is present (the
|
||||||
/// request proceeds unauthenticated; the route handler / `AccessControl`
|
/// request proceeds unauthenticated; the route handler / `AccessControl`
|
||||||
@@ -57,11 +98,62 @@ pub async fn bearer_auth_middleware(
|
|||||||
pub fn extract_bearer_identity(
|
pub fn extract_bearer_identity(
|
||||||
request: &Request,
|
request: &Request,
|
||||||
identity_provider: &dyn IdentityProvider,
|
identity_provider: &dyn IdentityProvider,
|
||||||
|
) -> Option<Identity> {
|
||||||
|
resolve_token_str(
|
||||||
|
bearer_token(request.headers().get(AUTHORIZATION)),
|
||||||
|
identity_provider,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the `Authorization` header with Bearer-first, Basic-tolerant
|
||||||
|
/// semantics (ADR-073): a `Basic base64(user:pass)` header resolves the
|
||||||
|
/// password field (the token; the Git-PAT shape — user, `x-access-token`
|
||||||
|
/// and similar are ignored by resolution) through the same
|
||||||
|
/// `IdentityProvider::resolve_from_token()` backend as a Bearer token.
|
||||||
|
/// A colon-less value is treated as a bare password. Unparseable or
|
||||||
|
/// unresolvable credentials resolve to `None` — the same no-token
|
||||||
|
/// fallback as [`extract_bearer_identity`].
|
||||||
|
pub fn extract_door_identity(
|
||||||
|
request: &Request,
|
||||||
|
identity_provider: &dyn IdentityProvider,
|
||||||
) -> Option<Identity> {
|
) -> Option<Identity> {
|
||||||
let header = request.headers().get(AUTHORIZATION)?;
|
let header = request.headers().get(AUTHORIZATION)?;
|
||||||
let token_str = header.to_str().ok()?.strip_prefix("Bearer ")?;
|
let credential = match bearer_token(Some(header)) {
|
||||||
|
Some(token) => token,
|
||||||
|
None => basic_password(header)?,
|
||||||
|
};
|
||||||
|
resolve_token_str(Some(credential), identity_provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bearer_token(header: Option<&http::HeaderValue>) -> Option<String> {
|
||||||
|
header?
|
||||||
|
.to_str()
|
||||||
|
.ok()?
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.map(str::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn basic_password(header: &http::HeaderValue) -> Option<String> {
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
let encoded = header.to_str().ok()?.strip_prefix("Basic ")?;
|
||||||
|
let decoded = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(encoded.trim())
|
||||||
|
.ok()?;
|
||||||
|
let decoded = String::from_utf8(decoded).ok()?;
|
||||||
|
let password = match decoded.split_once(':') {
|
||||||
|
Some((_user, password)) => password,
|
||||||
|
None => &decoded,
|
||||||
|
};
|
||||||
|
Some(password.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_token_str(
|
||||||
|
token_str: Option<String>,
|
||||||
|
identity_provider: &dyn IdentityProvider,
|
||||||
|
) -> Option<Identity> {
|
||||||
let token = AuthToken {
|
let token = AuthToken {
|
||||||
raw: token_str.as_bytes().to_vec(),
|
raw: token_str?.into_bytes(),
|
||||||
};
|
};
|
||||||
identity_provider.resolve_from_token(&token)
|
identity_provider.resolve_from_token(&token)
|
||||||
}
|
}
|
||||||
@@ -107,6 +199,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PasswordTokenProvider {
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityProvider for PasswordTokenProvider {
|
||||||
|
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||||
|
(String::from_utf8_lossy(&token.raw) == self.password).then(sample_identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct StaticProvider {
|
struct StaticProvider {
|
||||||
identity: Option<Identity>,
|
identity: Option<Identity>,
|
||||||
}
|
}
|
||||||
@@ -165,6 +270,49 @@ mod tests {
|
|||||||
assert!(identity.is_none());
|
assert!(identity.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn door_extract_resolves_basic_password_through_the_same_token_backend() {
|
||||||
|
let idp = PasswordTokenProvider {
|
||||||
|
password: "s3cret".to_string(),
|
||||||
|
};
|
||||||
|
let req = request_with_authorization(Some("Basic dXNlcjpzM2NyZXQ="));
|
||||||
|
let identity = crate::server::auth::extract_door_identity(&req, &idp);
|
||||||
|
assert!(identity.is_some(), "Basic password resolves as the token");
|
||||||
|
assert_eq!(identity.unwrap().id, "worker-a");
|
||||||
|
|
||||||
|
let req = request_with_authorization(Some("Basic OnMzY3JldA=="));
|
||||||
|
assert!(crate::server::auth::extract_door_identity(&req, &idp).is_some());
|
||||||
|
|
||||||
|
let req = request_with_authorization(Some("Bearer s3cret"));
|
||||||
|
assert!(crate::server::auth::extract_door_identity(&req, &idp).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn door_extract_treats_colonless_basic_as_a_bare_password() {
|
||||||
|
struct BareProvider;
|
||||||
|
impl IdentityProvider for BareProvider {
|
||||||
|
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||||
|
(String::from_utf8_lossy(&token.raw) == "baretok").then(sample_identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let req = request_with_authorization(Some("Basic YmFyZXRvaw=="));
|
||||||
|
let identity = crate::server::auth::extract_door_identity(&req, &BareProvider);
|
||||||
|
assert!(identity.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn door_extract_returns_none_for_invalid_base64_or_failing_resolution() {
|
||||||
|
let idp = provider(None);
|
||||||
|
let req = request_with_authorization(Some("Basic !not-base64!"));
|
||||||
|
assert!(crate::server::auth::extract_door_identity(&req, idp.as_ref()).is_none());
|
||||||
|
|
||||||
|
let req = request_with_authorization(Some("Basic dXNlcjpwYXNz"));
|
||||||
|
assert!(crate::server::auth::extract_door_identity(&req, idp.as_ref()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_returns_none_when_token_present_but_resolution_fails() {
|
fn extract_returns_none_when_token_present_but_resolution_fails() {
|
||||||
let idp = provider(None);
|
let idp = provider(None);
|
||||||
@@ -255,6 +403,38 @@ mod tests {
|
|||||||
assert_eq!(&bytes[..], b"none");
|
assert_eq!(&bytes[..], b"none");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn middleware_stashes_identity_for_basic_on_the_door_middleware() {
|
||||||
|
let idp: std::sync::Arc<dyn IdentityProvider> =
|
||||||
|
std::sync::Arc::new(PasswordTokenProvider {
|
||||||
|
password: "s3cret".to_string(),
|
||||||
|
});
|
||||||
|
let app: Router<()> = Router::new()
|
||||||
|
.route(
|
||||||
|
"/",
|
||||||
|
get(|req: Request| async move {
|
||||||
|
let identity = req
|
||||||
|
.extensions()
|
||||||
|
.get::<Option<Identity>>()
|
||||||
|
.cloned()
|
||||||
|
.flatten();
|
||||||
|
match identity {
|
||||||
|
Some(id) => (StatusCode::OK, id.id),
|
||||||
|
None => (StatusCode::OK, "none".to_string()),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.layer(from_fn_with_state(idp, door_auth_middleware));
|
||||||
|
|
||||||
|
let req = request_with_authorization(Some("Basic dXNlcjpzM2NyZXQ="));
|
||||||
|
let response = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&bytes[..], b"worker-a");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn resolved_identity_extractor_retrieves_stashed_some() {
|
async fn resolved_identity_extractor_retrieves_stashed_some() {
|
||||||
let idp = provider(Some(sample_identity()));
|
let idp = provider(Some(sample_identity()));
|
||||||
|
|||||||
+4
-1
@@ -5,7 +5,10 @@ pub mod healthz;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
pub use adapter::{HttpAdapter, RESERVED_PATHS, WS_UPGRADE_PATH};
|
pub use adapter::{HttpAdapter, RESERVED_PATHS, WS_UPGRADE_PATH};
|
||||||
pub use auth::{bearer_auth_middleware, extract_bearer_identity, ResolvedIdentity};
|
pub use auth::{
|
||||||
|
bearer_auth_middleware, door_auth_middleware, extract_bearer_identity, extract_door_identity,
|
||||||
|
ResolvedIdentity,
|
||||||
|
};
|
||||||
pub use decoy::{decoy_fallback, decoy_method_not_allowed};
|
pub use decoy::{decoy_fallback, decoy_method_not_allowed};
|
||||||
pub use healthz::healthz;
|
pub use healthz::healthz;
|
||||||
pub use state::DecoyConfig;
|
pub use state::DecoyConfig;
|
||||||
Reference in new issue
Block a user