release: remote HTTP MCP — bearer + OAuth, Docker image, CI publish, per-client docs #65
No files matched your search
@@ -133,6 +133,69 @@ def http_token() -> str:
|
|||||||
return (os.environ.get("FAMILIAR_HTTP_TOKEN") or "").strip()
|
return (os.environ.get("FAMILIAR_HTTP_TOKEN") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# Hosts that are *not* reachable from off-box. A bind to anything else is a
|
||||||
|
# public interface and must be authenticated (or explicitly waived).
|
||||||
|
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", ""}
|
||||||
|
|
||||||
|
|
||||||
|
def is_loopback(host: str) -> bool:
|
||||||
|
"""True if binding ``host`` only exposes the server to the local machine."""
|
||||||
|
return (host or "").strip().lower() in LOOPBACK_HOSTS
|
||||||
|
|
||||||
|
|
||||||
|
def _env_truthy(name: str) -> bool:
|
||||||
|
return (os.environ.get(name) or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def allow_insecure_bind() -> bool:
|
||||||
|
"""Escape hatch: allow a public (non-loopback) bind without a token.
|
||||||
|
|
||||||
|
Off by default. Only honor ``FAMILIAR_ALLOW_INSECURE_BIND`` when the operator
|
||||||
|
really means to run unauthenticated (e.g. auth handled entirely upstream).
|
||||||
|
"""
|
||||||
|
return _env_truthy("FAMILIAR_ALLOW_INSECURE_BIND")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# HTTP auth mode: bearer (default) or oauth (OAuth 2.1 resource server).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def auth_mode() -> str:
|
||||||
|
"""``bearer`` (default) or ``oauth`` — how the HTTP transport authenticates.
|
||||||
|
|
||||||
|
``bearer``: a single static token in ``FAMILIAR_HTTP_TOKEN``.
|
||||||
|
``oauth``: validate external-issuer JWTs (JWKS) and advertise the
|
||||||
|
protected-resource metadata so OAuth-only clients (e.g. Claude web) can
|
||||||
|
discover the authorization server.
|
||||||
|
"""
|
||||||
|
mode = (os.environ.get("FAMILIAR_AUTH_MODE") or "bearer").strip().lower()
|
||||||
|
return mode if mode in {"bearer", "oauth"} else "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_issuer() -> str:
|
||||||
|
return (os.environ.get("FAMILIAR_OAUTH_ISSUER") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_audience() -> str:
|
||||||
|
"""Expected token audience. Defaults to the resource URL when unset."""
|
||||||
|
aud = (os.environ.get("FAMILIAR_OAUTH_AUDIENCE") or "").strip()
|
||||||
|
return aud or oauth_resource()
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_resource() -> str:
|
||||||
|
"""This server's public base URL (the OAuth *resource*), e.g. https://host.
|
||||||
|
|
||||||
|
Used as the RemoteAuthProvider base_url and advertised in the
|
||||||
|
protected-resource metadata. Falls back to FAMILIAR_HTTP_PATH's origin only
|
||||||
|
if explicitly set; otherwise empty (serve will require it in oauth mode).
|
||||||
|
"""
|
||||||
|
return (os.environ.get("FAMILIAR_OAUTH_RESOURCE") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_jwks_uri() -> str:
|
||||||
|
"""Explicit JWKS URI, or empty to discover it from the issuer at start."""
|
||||||
|
return (os.environ.get("FAMILIAR_OAUTH_JWKS_URI") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def data_dir() -> Path:
|
def data_dir() -> Path:
|
||||||
"""Where the encrypted token and key live. Created with restricted perms."""
|
"""Where the encrypted token and key live. Created with restricted perms."""
|
||||||
override = os.environ.get("FAMILIAR_DATA_DIR")
|
override = os.environ.get("FAMILIAR_DATA_DIR")
|
||||||
|
|||||||
+104
-15
@@ -8,8 +8,11 @@ same `mcp` instance.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
|
import sys
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
__version__,
|
__version__,
|
||||||
@@ -47,6 +50,16 @@ mcp: FastMCP = FastMCP(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.custom_route("/healthz", methods=["GET"])
|
||||||
|
async def healthz(_request: Request) -> JSONResponse:
|
||||||
|
"""Unauthenticated liveness probe for Docker / reverse-proxy healthchecks.
|
||||||
|
|
||||||
|
Only reachable over the HTTP transport; on stdio the route is never served.
|
||||||
|
Deliberately outside the bearer-auth gate so a probe needs no token.
|
||||||
|
"""
|
||||||
|
return JSONResponse({"status": "ok", "server": "familiar", "version": __version__})
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
def ping() -> dict[str, str]:
|
def ping() -> dict[str, str]:
|
||||||
"""Health check. Confirms the familiar server is alive and reports its version.
|
"""Health check. Confirms the familiar server is alive and reports its version.
|
||||||
@@ -126,21 +139,96 @@ def _bearer_verifier(token: str):
|
|||||||
return StaticTokenVerifier(tokens={token: {"client_id": "familiar-remote", "scopes": []}})
|
return StaticTokenVerifier(tokens={token: {"client_id": "familiar-remote", "scopes": []}})
|
||||||
|
|
||||||
|
|
||||||
def configure_http(token: str | None = None) -> str:
|
def _resolve_jwks_uri(issuer: str, jwks_uri: str) -> str:
|
||||||
"""Prepare the shared ``mcp`` instance for HTTP serving and return the token.
|
"""Return the JWKS URI, discovering it from the issuer's OIDC metadata if unset."""
|
||||||
|
if jwks_uri:
|
||||||
|
return jwks_uri
|
||||||
|
import httpx
|
||||||
|
|
||||||
Attaches bearer auth and switches writes to their network default (disabled
|
disco = issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||||
unless FAMILIAR_ENABLE_WRITES is explicitly truthy). Raises ``SystemExit``
|
try:
|
||||||
if no token is configured — an internet-reachable server must never run open.
|
resp = httpx.get(disco, timeout=10.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
uri = resp.json().get("jwks_uri")
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface any discovery failure as a clean exit
|
||||||
|
raise SystemExit(
|
||||||
|
f"OAuth discovery failed at {disco}: {exc}. Set FAMILIAR_OAUTH_JWKS_URI explicitly."
|
||||||
|
) from exc
|
||||||
|
if not uri:
|
||||||
|
raise SystemExit(f"OIDC metadata at {disco} has no jwks_uri; set FAMILIAR_OAUTH_JWKS_URI.")
|
||||||
|
return uri
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_provider(verifier=None):
|
||||||
|
"""Build the OAuth 2.1 resource-server provider (validates external JWTs).
|
||||||
|
|
||||||
|
familiar acts as a *resource server* only: it validates incoming JWT access
|
||||||
|
tokens against an external issuer's JWKS and advertises
|
||||||
|
``/.well-known/oauth-protected-resource`` so OAuth-only clients can discover
|
||||||
|
the authorization server. It is NOT an authorization server and does no DCR.
|
||||||
|
``verifier`` is injectable for tests; production builds a JWTVerifier.
|
||||||
"""
|
"""
|
||||||
|
from pydantic import AnyHttpUrl
|
||||||
|
|
||||||
|
from fastmcp.server.auth import RemoteAuthProvider
|
||||||
|
|
||||||
|
issuer = config.oauth_issuer()
|
||||||
|
resource = config.oauth_resource()
|
||||||
|
if not issuer or not resource:
|
||||||
|
raise SystemExit(
|
||||||
|
"oauth mode needs FAMILIAR_OAUTH_ISSUER (the authorization server) and "
|
||||||
|
"FAMILIAR_OAUTH_RESOURCE (this server's public https URL)."
|
||||||
|
)
|
||||||
|
if verifier is None:
|
||||||
|
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||||
|
|
||||||
|
jwks = _resolve_jwks_uri(issuer, config.oauth_jwks_uri())
|
||||||
|
verifier = JWTVerifier(jwks_uri=jwks, issuer=issuer, audience=config.oauth_audience())
|
||||||
|
return RemoteAuthProvider(
|
||||||
|
token_verifier=verifier,
|
||||||
|
authorization_servers=[AnyHttpUrl(issuer)],
|
||||||
|
base_url=resource,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _refuse_no_token(host: str) -> "SystemExit":
|
||||||
|
where = "a public interface" if not config.is_loopback(host) else "loopback"
|
||||||
|
return SystemExit(
|
||||||
|
f"Refusing to start the HTTP server on {host!r} ({where}) without a bearer "
|
||||||
|
"token. Set FAMILIAR_HTTP_TOKEN to a long random secret, switch to "
|
||||||
|
"FAMILIAR_AUTH_MODE=oauth, or — only if auth is handled upstream — set "
|
||||||
|
"FAMILIAR_ALLOW_INSECURE_BIND=true to run unauthenticated."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_http(*, host: str | None = None, token: str | None = None, verifier=None) -> str:
|
||||||
|
"""Prepare the shared ``mcp`` instance for HTTP serving; return the mode/token.
|
||||||
|
|
||||||
|
Attaches the auth provider for the configured ``FAMILIAR_AUTH_MODE`` and
|
||||||
|
switches writes to their network default (disabled unless
|
||||||
|
FAMILIAR_ENABLE_WRITES is explicitly truthy). Fail-closed: a bind without a
|
||||||
|
token (bearer mode) raises ``SystemExit`` unless FAMILIAR_ALLOW_INSECURE_BIND
|
||||||
|
is set. ``verifier`` is injectable for oauth-mode tests.
|
||||||
|
"""
|
||||||
|
host = host if host is not None else config.HTTP_HOST
|
||||||
|
writes.set_http_context(True)
|
||||||
|
|
||||||
|
if config.auth_mode() == "oauth":
|
||||||
|
mcp.auth = _oauth_provider(verifier=verifier)
|
||||||
|
return "oauth"
|
||||||
|
|
||||||
token = token if token is not None else config.http_token()
|
token = token if token is not None else config.http_token()
|
||||||
if not token:
|
if not token:
|
||||||
raise SystemExit(
|
if config.allow_insecure_bind():
|
||||||
"Refusing to start the HTTP server: FAMILIAR_HTTP_TOKEN is not set. "
|
print(
|
||||||
"Set it to a long random secret (the bearer token your MCP client "
|
f"WARNING: serving familiar on {host!r} with NO authentication "
|
||||||
"sends) and try again."
|
"(FAMILIAR_ALLOW_INSECURE_BIND is set). Anyone who can reach this "
|
||||||
)
|
"port has full access to your account.",
|
||||||
writes.set_http_context(True)
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
mcp.auth = None
|
||||||
|
return ""
|
||||||
|
raise _refuse_no_token(host)
|
||||||
mcp.auth = _bearer_verifier(token)
|
mcp.auth = _bearer_verifier(token)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
@@ -148,16 +236,17 @@ def configure_http(token: str | None = None) -> str:
|
|||||||
def serve_http(
|
def serve_http(
|
||||||
*, host: str | None = None, port: int | None = None, path: str | None = None
|
*, host: str | None = None, port: int | None = None, path: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run familiar over the streamable-HTTP transport with bearer auth.
|
"""Run familiar over the streamable-HTTP transport.
|
||||||
|
|
||||||
Binds loopback by default (a TLS reverse proxy is expected to terminate
|
Binds loopback by default (a TLS reverse proxy is expected to terminate
|
||||||
HTTPS and forward here). Host/port/path fall back to the FAMILIAR_HTTP_*
|
HTTPS and forward here). Host/port/path fall back to the FAMILIAR_HTTP_*
|
||||||
config. Refuses to start without FAMILIAR_HTTP_TOKEN.
|
config. Authentication follows FAMILIAR_AUTH_MODE (bearer by default).
|
||||||
"""
|
"""
|
||||||
configure_http()
|
host = host or config.HTTP_HOST
|
||||||
|
configure_http(host=host)
|
||||||
mcp.run(
|
mcp.run(
|
||||||
transport="http",
|
transport="http",
|
||||||
host=host or config.HTTP_HOST,
|
host=host,
|
||||||
port=port or config.HTTP_PORT,
|
port=port or config.HTTP_PORT,
|
||||||
path=path or config.HTTP_PATH,
|
path=path or config.HTTP_PATH,
|
||||||
)
|
)
|
||||||
@@ -79,6 +79,20 @@ def test_valid_token_passes_handshake(http_server):
|
|||||||
assert status == 200
|
assert status == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz_needs_no_token(http_server):
|
||||||
|
_, app = http_server
|
||||||
|
|
||||||
|
async def _get_health():
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
return await client.get("/healthz")
|
||||||
|
|
||||||
|
resp = asyncio.run(_get_health())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Start refusal without a token
|
# Start refusal without a token
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -214,3 +228,166 @@ def test_manual_login_exchanges_and_saves(monkeypatch, tmp_path):
|
|||||||
assert bundle["character_id"] == 42
|
assert bundle["character_id"] == 42
|
||||||
assert bundle["character_name"] == "Pilot"
|
assert bundle["character_name"] == "Pilot"
|
||||||
assert saved["character_id"] == 42
|
assert saved["character_id"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fail-closed bind + insecure escape hatch
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _reset_auth():
|
||||||
|
server.mcp.auth = None
|
||||||
|
writes.set_http_context(False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_bind_without_token_refused(monkeypatch):
|
||||||
|
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False)
|
||||||
|
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
|
||||||
|
try:
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
server.configure_http(host="0.0.0.0")
|
||||||
|
assert "public interface" in str(excinfo.value)
|
||||||
|
finally:
|
||||||
|
_reset_auth()
|
||||||
|
|
||||||
|
|
||||||
|
def test_loopback_without_token_still_refused(monkeypatch):
|
||||||
|
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False)
|
||||||
|
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
|
||||||
|
try:
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
server.configure_http(host="127.0.0.1")
|
||||||
|
finally:
|
||||||
|
_reset_auth()
|
||||||
|
|
||||||
|
|
||||||
|
def test_insecure_bind_escape_hatch_allows_tokenless(monkeypatch):
|
||||||
|
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
|
||||||
|
monkeypatch.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "true")
|
||||||
|
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
|
||||||
|
try:
|
||||||
|
result = server.configure_http(host="0.0.0.0")
|
||||||
|
assert result == ""
|
||||||
|
assert server.mcp.auth is None
|
||||||
|
finally:
|
||||||
|
_reset_auth()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# OAuth resource-server mode
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
_ISS = "https://idp.example.com"
|
||||||
|
_RES = "https://familiar.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def oauth_server(monkeypatch):
|
||||||
|
"""Configure oauth mode with an injected JWTVerifier over a local RSA key."""
|
||||||
|
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
|
||||||
|
|
||||||
|
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "oauth")
|
||||||
|
monkeypatch.setenv("FAMILIAR_OAUTH_ISSUER", _ISS)
|
||||||
|
monkeypatch.setenv("FAMILIAR_OAUTH_RESOURCE", _RES)
|
||||||
|
monkeypatch.setenv("FAMILIAR_OAUTH_AUDIENCE", _RES)
|
||||||
|
original = server.mcp.auth
|
||||||
|
kp = RSAKeyPair.generate()
|
||||||
|
verifier = JWTVerifier(public_key=kp.public_key, issuer=_ISS, audience=_RES)
|
||||||
|
server.configure_http(host="0.0.0.0", verifier=verifier)
|
||||||
|
app = server.mcp.http_app(path="/mcp")
|
||||||
|
yield kp, app
|
||||||
|
server.mcp.auth = original
|
||||||
|
writes.set_http_context(False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_valid_jwt_passes(oauth_server):
|
||||||
|
kp, app = oauth_server
|
||||||
|
token = kp.create_token(issuer=_ISS, audience=_RES, subject="user1")
|
||||||
|
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_missing_token_401(oauth_server):
|
||||||
|
_, app = oauth_server
|
||||||
|
status, www_auth = asyncio.run(_status_for(app, None))
|
||||||
|
assert status == 401
|
||||||
|
assert www_auth and www_auth.lower().startswith("bearer")
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_wrong_audience_401(oauth_server):
|
||||||
|
kp, app = oauth_server
|
||||||
|
token = kp.create_token(issuer=_ISS, audience="https://other.example.com", subject="user1")
|
||||||
|
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_expired_token_401(oauth_server):
|
||||||
|
kp, app = oauth_server
|
||||||
|
token = kp.create_token(issuer=_ISS, audience=_RES, subject="user1", expires_in_seconds=-60)
|
||||||
|
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_protected_resource_metadata(oauth_server):
|
||||||
|
_, app = oauth_server
|
||||||
|
|
||||||
|
async def _get_metadata():
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
return await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||||
|
|
||||||
|
resp = asyncio.run(_get_metadata())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["resource"] == f"{_RES}/mcp"
|
||||||
|
assert body["authorization_servers"] == [f"{_ISS}/"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_requires_issuer_and_resource(monkeypatch):
|
||||||
|
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "oauth")
|
||||||
|
monkeypatch.delenv("FAMILIAR_OAUTH_ISSUER", raising=False)
|
||||||
|
monkeypatch.delenv("FAMILIAR_OAUTH_RESOURCE", raising=False)
|
||||||
|
try:
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
server.configure_http(host="0.0.0.0")
|
||||||
|
assert "FAMILIAR_OAUTH_ISSUER" in str(excinfo.value)
|
||||||
|
finally:
|
||||||
|
_reset_auth()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_jwks_uri_passthrough():
|
||||||
|
assert server._resolve_jwks_uri(_ISS, "https://idp/keys") == "https://idp/keys"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Config readers for the new env vars
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_is_loopback():
|
||||||
|
assert config.is_loopback("127.0.0.1")
|
||||||
|
assert config.is_loopback("localhost")
|
||||||
|
assert not config.is_loopback("0.0.0.0")
|
||||||
|
assert not config.is_loopback("192.168.1.10")
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_mode_default_and_values(monkeypatch):
|
||||||
|
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
|
||||||
|
assert config.auth_mode() == "bearer"
|
||||||
|
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "OAuth")
|
||||||
|
assert config.auth_mode() == "oauth"
|
||||||
|
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "nonsense")
|
||||||
|
assert config.auth_mode() == "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_allow_insecure_bind(monkeypatch):
|
||||||
|
monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False)
|
||||||
|
assert config.allow_insecure_bind() is False
|
||||||
|
monkeypatch.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "yes")
|
||||||
|
assert config.allow_insecure_bind() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_audience_defaults_to_resource(monkeypatch):
|
||||||
|
monkeypatch.setenv("FAMILIAR_OAUTH_RESOURCE", _RES)
|
||||||
|
monkeypatch.delenv("FAMILIAR_OAUTH_AUDIENCE", raising=False)
|
||||||
|
assert config.oauth_audience() == _RES
|
||||||
|
monkeypatch.setenv("FAMILIAR_OAUTH_AUDIENCE", "custom-aud")
|
||||||
|
assert config.oauth_audience() == "custom-aud"
|
||||||
Reference in new issue
Block a user