chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.server.auth.config import (AuthConfig, AuthMode, JWTConfig,
|
||||
resolve_auth_config,
|
||||
resolve_local_token)
|
||||
from mirage.server.auth.middleware import AuthMiddleware
|
||||
from mirage.server.auth.storage import (default_token_file, ensure_token_file,
|
||||
read_token_file)
|
||||
|
||||
__all__ = [
|
||||
"AuthConfig",
|
||||
"AuthMiddleware",
|
||||
"AuthMode",
|
||||
"JWTConfig",
|
||||
"default_token_file",
|
||||
"ensure_token_file",
|
||||
"read_token_file",
|
||||
"resolve_auth_config",
|
||||
"resolve_local_token",
|
||||
]
|
||||
@@ -0,0 +1,208 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from mirage.server.auth import storage as _storage
|
||||
from mirage.server.daemon_config import read_daemon_table
|
||||
from mirage.server.paths import mirage_home
|
||||
|
||||
ENV_AUTH_MODE = "MIRAGE_AUTH_MODE"
|
||||
ENV_AUTH_TOKEN = "MIRAGE_AUTH_TOKEN"
|
||||
ENV_JWT_PUBKEY = "MIRAGE_JWT_PUBKEY"
|
||||
ENV_JWT_PUBKEY_FILE = "MIRAGE_JWT_PUBKEY_FILE"
|
||||
ENV_JWT_ALG = "MIRAGE_JWT_ALG"
|
||||
ENV_JWT_ISSUER = "MIRAGE_JWT_ISSUER"
|
||||
ENV_JWT_AUDIENCE = "MIRAGE_JWT_AUDIENCE"
|
||||
ENV_JWT_AUTHORIZED_PARTIES = "MIRAGE_JWT_AUTHORIZED_PARTIES"
|
||||
ENV_JWT_CLOCK_SKEW = "MIRAGE_JWT_CLOCK_SKEW_SECONDS"
|
||||
|
||||
DEFAULT_CLOCK_SKEW_SECONDS = 5
|
||||
|
||||
|
||||
class AuthMode(StrEnum):
|
||||
LOCAL = "local"
|
||||
TOKEN = "token"
|
||||
JWT = "jwt"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JWTConfig:
|
||||
key: str
|
||||
algorithm: str
|
||||
issuer: str | None = None
|
||||
audience: str | None = None
|
||||
authorized_parties: tuple[str, ...] = field(default_factory=tuple)
|
||||
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfig:
|
||||
mode: AuthMode
|
||||
local_token: str | None = None
|
||||
bearer_token: str | None = None
|
||||
jwt: JWTConfig | None = None
|
||||
|
||||
|
||||
def resolve_local_token(
|
||||
env: Mapping[str, str] | None = None,
|
||||
token_file: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve the local-mode bearer token via env > file > None.
|
||||
|
||||
Args:
|
||||
env (Mapping[str, str] | None): environment to read
|
||||
``MIRAGE_AUTH_TOKEN`` from. Defaults to ``os.environ``.
|
||||
token_file (Path | None): location of the token file.
|
||||
Defaults to ``default_token_file()``.
|
||||
|
||||
Returns:
|
||||
str | None: resolved token, or ``None`` if no source provides one.
|
||||
"""
|
||||
e = env if env is not None else os.environ
|
||||
val = e.get(ENV_AUTH_TOKEN, "").strip()
|
||||
if val:
|
||||
return val
|
||||
path = (token_file
|
||||
if token_file is not None else _storage.default_token_file())
|
||||
return _storage.read_token_file(path)
|
||||
|
||||
|
||||
def _read_jwt_key(env: Mapping[str, str]) -> str:
|
||||
inline = env.get(ENV_JWT_PUBKEY, "").strip()
|
||||
if inline:
|
||||
return inline
|
||||
path = env.get(ENV_JWT_PUBKEY_FILE, "").strip()
|
||||
if path:
|
||||
return Path(path).read_text()
|
||||
raise RuntimeError(
|
||||
f"mode=jwt requires {ENV_JWT_PUBKEY} or {ENV_JWT_PUBKEY_FILE}")
|
||||
|
||||
|
||||
def _parse_csv(value: str) -> tuple[str, ...]:
|
||||
return tuple(p.strip() for p in value.split(",") if p.strip())
|
||||
|
||||
|
||||
_CONFIG_ENV_KEYS = {
|
||||
"auth_mode": ENV_AUTH_MODE,
|
||||
"jwt_alg": ENV_JWT_ALG,
|
||||
"jwt_issuer": ENV_JWT_ISSUER,
|
||||
"jwt_audience": ENV_JWT_AUDIENCE,
|
||||
"jwt_pubkey_file": ENV_JWT_PUBKEY_FILE,
|
||||
"jwt_clock_skew": ENV_JWT_CLOCK_SKEW,
|
||||
"jwt_authorized_parties": ENV_JWT_AUTHORIZED_PARTIES,
|
||||
}
|
||||
|
||||
|
||||
def _merge_config_table(env: Mapping[str, str],
|
||||
table: Mapping[str, object]) -> dict[str, str]:
|
||||
"""Fold config.toml auth keys under their env names, env winning.
|
||||
|
||||
Only non-secret keys have config counterparts: the raw
|
||||
``MIRAGE_AUTH_TOKEN`` and inline ``MIRAGE_JWT_PUBKEY`` stay
|
||||
env-only (use the token file / ``jwt_pubkey_file`` instead).
|
||||
|
||||
Args:
|
||||
env (Mapping[str, str]): the process environment view.
|
||||
table (Mapping[str, object]): the ``[daemon]`` config table.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: env copy with config fallbacks applied.
|
||||
"""
|
||||
merged = dict(env)
|
||||
for cfg_key, env_name in _CONFIG_ENV_KEYS.items():
|
||||
if merged.get(env_name, "").strip():
|
||||
continue
|
||||
value = table.get(cfg_key)
|
||||
if value is not None and str(value).strip():
|
||||
merged[env_name] = str(value)
|
||||
return merged
|
||||
|
||||
|
||||
def resolve_auth_config(
|
||||
env: Mapping[str, str] | None = None,
|
||||
token_file: Path | None = None,
|
||||
table: Mapping[str, object] | None = None,
|
||||
) -> AuthConfig:
|
||||
"""Resolve daemon auth configuration from environment and config.
|
||||
|
||||
Per key the environment variable wins over the ``[daemon]`` table
|
||||
in ``config.toml``, which wins over the default.
|
||||
|
||||
Args:
|
||||
env (Mapping[str, str] | None): environment to read from.
|
||||
Defaults to ``os.environ``.
|
||||
token_file (Path | None): override the local-mode token file
|
||||
location. Defaults to ``default_token_file()``.
|
||||
table (Mapping[str, object] | None): the ``[daemon]`` config
|
||||
table. Defaults to reading ``$MIRAGE_HOME/config.toml``
|
||||
when ``env`` is also defaulted; an explicit ``env`` with no
|
||||
``table`` stays hermetic and reads no file.
|
||||
|
||||
Returns:
|
||||
AuthConfig: resolved configuration.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if required settings are missing for the chosen mode.
|
||||
"""
|
||||
if table is None:
|
||||
table = read_daemon_table(mirage_home()) if env is None else {}
|
||||
e = _merge_config_table(env if env is not None else os.environ, table)
|
||||
raw_mode = (e.get(ENV_AUTH_MODE, "")
|
||||
or AuthMode.LOCAL.value).strip().lower()
|
||||
try:
|
||||
mode = AuthMode(raw_mode)
|
||||
except ValueError as exc:
|
||||
valid = ", ".join(m.value for m in AuthMode)
|
||||
raise RuntimeError(
|
||||
f"{ENV_AUTH_MODE} must be one of ({valid}), got {raw_mode!r}"
|
||||
) from exc
|
||||
|
||||
if mode == AuthMode.LOCAL:
|
||||
return AuthConfig(
|
||||
mode=mode,
|
||||
local_token=resolve_local_token(env=e, token_file=token_file),
|
||||
)
|
||||
|
||||
if mode == AuthMode.TOKEN:
|
||||
token = e.get(ENV_AUTH_TOKEN, "").strip()
|
||||
if not token:
|
||||
raise RuntimeError(
|
||||
f"mode=token requires {ENV_AUTH_TOKEN} to be set")
|
||||
return AuthConfig(mode=mode, bearer_token=token)
|
||||
|
||||
key = _read_jwt_key(e)
|
||||
alg = e.get(ENV_JWT_ALG, "").strip()
|
||||
if not alg:
|
||||
raise RuntimeError(f"mode=jwt requires {ENV_JWT_ALG} (e.g. RS256)")
|
||||
issuer = (e.get(ENV_JWT_ISSUER) or "").strip() or None
|
||||
audience = (e.get(ENV_JWT_AUDIENCE) or "").strip() or None
|
||||
azp = _parse_csv(e.get(ENV_JWT_AUTHORIZED_PARTIES, ""))
|
||||
skew_raw = (e.get(ENV_JWT_CLOCK_SKEW) or "").strip()
|
||||
skew = int(skew_raw) if skew_raw else DEFAULT_CLOCK_SKEW_SECONDS
|
||||
return AuthConfig(
|
||||
mode=mode,
|
||||
jwt=JWTConfig(
|
||||
key=key,
|
||||
algorithm=alg,
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
authorized_parties=azp,
|
||||
clock_skew_seconds=skew,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import jwt as pyjwt
|
||||
|
||||
from mirage.server.auth.config import JWTConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JWTVerificationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def verify_jwt(token: str, cfg: JWTConfig) -> dict[str, Any]:
|
||||
"""Verify a JWT against ``cfg`` and return its claims on success.
|
||||
|
||||
Performs signature verification, algorithm pinning, mandatory
|
||||
``exp`` check, and (when configured) ``iss``/``aud``/``azp``
|
||||
checks. ``typ`` header, if present, must be ``"JWT"``.
|
||||
|
||||
Args:
|
||||
token (str): the raw bearer value (already stripped of any
|
||||
``Bearer `` prefix).
|
||||
cfg (JWTConfig): verification parameters.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: validated claims.
|
||||
|
||||
Raises:
|
||||
JWTVerificationError: any failure (signature, algorithm,
|
||||
``exp``, ``iss``, ``aud``, ``azp``, ``typ``).
|
||||
"""
|
||||
try:
|
||||
claims = pyjwt.decode(
|
||||
token,
|
||||
cfg.key,
|
||||
algorithms=[cfg.algorithm],
|
||||
audience=cfg.audience,
|
||||
issuer=cfg.issuer,
|
||||
options={"require": ["exp"]},
|
||||
leeway=cfg.clock_skew_seconds,
|
||||
)
|
||||
except pyjwt.PyJWTError as e:
|
||||
raise JWTVerificationError(f"JWT rejected: {e}") from e
|
||||
try:
|
||||
header = pyjwt.get_unverified_header(token)
|
||||
except pyjwt.PyJWTError as e:
|
||||
raise JWTVerificationError(f"JWT header unreadable: {e}") from e
|
||||
typ = header.get("typ")
|
||||
if typ is not None and typ != "JWT":
|
||||
raise JWTVerificationError(
|
||||
f"JWT typ header must be 'JWT' when present, got {typ!r}")
|
||||
if cfg.authorized_parties:
|
||||
azp = claims.get("azp")
|
||||
if azp not in cfg.authorized_parties:
|
||||
raise JWTVerificationError(
|
||||
f"JWT azp {azp!r} not in authorized_parties")
|
||||
return claims
|
||||
@@ -0,0 +1,101 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import re
|
||||
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from mirage.server.auth.config import AuthConfig, AuthMode
|
||||
from mirage.server.auth.jwt import JWTVerificationError, verify_jwt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BEARER_PREFIX = "Bearer "
|
||||
HEALTH_PATHS: frozenset[str] = frozenset({"/v1/health"})
|
||||
_JWT_SHAPE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
class AuthMiddleware:
|
||||
"""ASGI middleware enforcing the daemon's auth mode.
|
||||
|
||||
Args:
|
||||
app (ASGIApp): downstream ASGI app.
|
||||
config (AuthConfig): resolved auth configuration.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, config: AuthConfig) -> None:
|
||||
self.app = app
|
||||
self.config = config
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive,
|
||||
send: Send) -> None:
|
||||
if scope["type"] not in ("http", "websocket"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
if scope.get("path") in HEALTH_PATHS:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
cfg = self.config
|
||||
if cfg.mode == AuthMode.LOCAL and cfg.local_token is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
token = self._extract_bearer(scope)
|
||||
if token is None:
|
||||
await self._unauthorized(scope, receive, send,
|
||||
"missing bearer token")
|
||||
return
|
||||
|
||||
if self.config.mode == AuthMode.JWT:
|
||||
if not _JWT_SHAPE.match(token):
|
||||
await self._unauthorized(scope, receive, send,
|
||||
"token shape is not a JWT")
|
||||
return
|
||||
try:
|
||||
verify_jwt(token, self.config.jwt)
|
||||
except JWTVerificationError as e:
|
||||
logger.debug("JWT rejected: %s", e)
|
||||
await self._unauthorized(scope, receive, send, str(e))
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
expected = (self.config.local_token if self.config.mode
|
||||
== AuthMode.LOCAL else self.config.bearer_token)
|
||||
if expected is None or not hmac.compare_digest(token, expected):
|
||||
await self._unauthorized(scope, receive, send, "bearer mismatch")
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
@staticmethod
|
||||
def _extract_bearer(scope: Scope) -> str | None:
|
||||
headers = dict(scope.get("headers") or [])
|
||||
raw = headers.get(b"authorization", b"").decode("latin-1")
|
||||
if not raw.startswith(BEARER_PREFIX):
|
||||
return None
|
||||
value = raw[len(BEARER_PREFIX):].strip()
|
||||
return value or None
|
||||
|
||||
async def _unauthorized(self, scope: Scope, receive: Receive, send: Send,
|
||||
reason: str) -> None:
|
||||
client = scope.get("client") or ("?", 0)
|
||||
logger.warning("rejecting request from %s:%s: %s", client[0],
|
||||
client[1], reason)
|
||||
response = PlainTextResponse("Unauthorized",
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"})
|
||||
await response(scope, receive, send)
|
||||
@@ -0,0 +1,57 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from mirage.server.paths import mirage_home
|
||||
|
||||
|
||||
def default_token_file() -> Path:
|
||||
"""Default auth token location, under :func:`mirage_home`."""
|
||||
return mirage_home() / "auth_token"
|
||||
|
||||
|
||||
def read_token_file(path: Path) -> str | None:
|
||||
"""Read the token file if present, returning None when missing.
|
||||
|
||||
Args:
|
||||
path (Path): location of the token file.
|
||||
|
||||
Returns:
|
||||
str | None: stripped file contents, or None if the file does not exist.
|
||||
"""
|
||||
if not path.exists():
|
||||
return None
|
||||
return path.read_text().strip()
|
||||
|
||||
|
||||
def ensure_token_file(path: Path) -> str:
|
||||
"""Read the token file if present, else mint and persist a fresh one.
|
||||
|
||||
Args:
|
||||
path (Path): location of the token file.
|
||||
|
||||
Returns:
|
||||
str: the resolved bearer token.
|
||||
"""
|
||||
existing = read_token_file(path)
|
||||
if existing is not None:
|
||||
return existing
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token = secrets.token_urlsafe(32)
|
||||
path.write_text(token)
|
||||
os.chmod(path, 0o600)
|
||||
return token
|
||||
Reference in New Issue
Block a user