Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions .github/workflows/service-auth-py-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
name: Publish fuzefront-service-auth (Python)

# Builds the wheel + sdist for packages/service-auth-py and attaches them to
# a GitHub Release, so Python microservices in other family repos can install
# a PINNED artifact rather than tracking a branch.
#
# Mirrors identity-py-publish.yml / config-client-py-publish.yml exactly (see
# either file's comment for the "why a Release asset and not a registry"
# rationale): GitHub Packages has no PyPI-compatible registry, and this is a
# private/proprietary family package so public PyPI is out. A Release asset
# is the one private-by-default artifact store the family already
# authenticates against (the same GITHUB_TOKEN that serves npm.pkg.github.com).
#
# Consumers install either:
# pip install https://github.com/izzywdev/FuzeFront/releases/download/service-auth-py-vX.Y.Z/fuzefront_service_auth-X.Y.Z-py3-none-any.whl
# pip install "git+https://github.com/izzywdev/FuzeFront.git@service-auth-py-vX.Y.Z#subdirectory=packages/service-auth-py"
# The first is preferred -- it is a pinned immutable artifact and needs no git.

on:
push:
tags: ['service-auth-py-v*']
workflow_dispatch:
inputs:
version:
description: 'Version to build (must match pyproject.toml)'
required: true

permissions:
contents: write # create the Release + upload assets

concurrency:
group: service-auth-py-publish
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

- uses: actions/setup-python@v7
with: { python-version: '3.12' }

- name: Verify the tag matches pyproject version
# A tag that disagrees with pyproject produces a wheel whose filename
# and metadata contradict the release it is attached to -- the kind
# of mismatch that is only discovered by a consumer, months later.
#
# `inputs.version` and `github.*` reach the shell through env, NEVER
# through ${{ }} interpolation in `run:`. A dispatch input is
# arbitrary attacker-supplied text; interpolated directly it is a
# command-injection sink, and this job holds `contents: write` and
# publishes an artifact consumers install -- the worst possible place
# to have one.
#
# Also emits `version` (the bare X.Y.Z, no 'service-auth-py-v'
# prefix) as a step output, so the Release body below can build the
# correct wheel filename -- the wheel is
# `fuzefront_service_auth-X.Y.Z-py3-none-any.whl`, NOT
# `...-service-auth-py-vX.Y.Z-...`.
id: version
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
cd packages/service-auth-py
PY_VER=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
TAG_VER="$INPUT_VERSION"
else
TAG_VER="${REF_NAME#service-auth-py-v}"
fi
echo "pyproject=$PY_VER tag=$TAG_VER"
[ "$PY_VER" = "$TAG_VER" ] || { echo "::error::pyproject version $PY_VER != tag $TAG_VER"; exit 1; }
echo "version=$PY_VER" >> "$GITHUB_OUTPUT"

- name: Test before publishing
# The core client/verifier are dependency-free, so most of the suite
# needs no services; the middleware tests install fastapi/flask via
# the dev extra. There is no excuse for shipping this untested --
# this package's entire reason to exist is a fail-closed guarantee.
run: |
cd packages/service-auth-py
pip install -q -e '.[dev]'
pytest -q

- name: Build wheel + sdist
run: |
cd packages/service-auth-py
pip install -q build
python -m build

- name: Verify dist contents
# A green build step is not evidence that anything was produced --
# confirm the wheel actually exists before attaching it to a Release.
run: |
cd packages/service-auth-py
ls -lh dist/
if [ -z "$(ls dist/*.whl 2>/dev/null)" ]; then
echo "::error::No wheel found in dist/ -- build failed silently."
exit 1
fi

- name: Attach to the Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2
with:
files: packages/service-auth-py/dist/*
tag_name: ${{ github.ref_name }}
name: fuzefront-service-auth ${{ github.ref_name }}
body: |
Python service-to-service (M2M) auth client + verifier + FastAPI/
Flask middleware for the FuzeFront Security API.

Contract: `packages/security/openapi.yaml` (`tokens` tag)
TypeScript peer: `packages/service-auth`

```bash
pip install https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/fuzefront_service_auth-${{ steps.version.outputs.version }}-py3-none-any.whl
```
fail_on_unmatched_files: true
7 changes: 7 additions & 0 deletions packages/service-auth-py/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.pytest_cache/
209 changes: 209 additions & 0 deletions packages/service-auth-py/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# fuzefront-service-auth

Python service-to-service (S2S) auth for **any** FuzeFront-family microservice
-- a caller-side client that acquires/caches machine-to-machine (M2M) access
tokens, and a callee-side verifier + framework middleware that validates
them. The Python peer of the TypeScript `packages/service-auth` runtime and
of the generated `@fuzefront/security-client` types.

Hand-authored from
[`packages/security/openapi.yaml`](../security/openapi.yaml) (`tokens` tag:
`POST /v1/security/tokens`, `POST /v1/security/tokens/introspect`). **That
spec is the frozen contract; this package is a projection of it.** If the
two disagree, the spec wins and this package is the bug.

Both halves talk ONLY to FuzeFront's own Security API -- never to the
underlying identity provider, which the contract deliberately hides. Core
client/verifier code is dependency-free (stdlib `urllib` only, mirroring
`fuzefront-config-client`/`fuzefront-identity`); framework middleware is
behind optional extras so it never forces a web framework onto a caller-only
service.

## Install

Distributed as a **GitHub Release asset** (wheel + sdist) -- the same
mechanism `packages/identity-py` and `packages/config-client-py` use.
GitHub Packages has no PyPI-style registry, and this is a
private/proprietary family package, so a signed Release asset is the
private-by-default artifact store the family already authenticates against:

```bash
pip install https://github.com/izzywdev/FuzeFront/releases/download/service-auth-py-v1.0.0/fuzefront_service_auth-1.0.0-py3-none-any.whl

# with framework middleware:
pip install "fuzefront-service-auth[fastapi] @ https://github.com/izzywdev/FuzeFront/releases/download/service-auth-py-v1.0.0/fuzefront_service_auth-1.0.0-py3-none-any.whl"
```

## Caller side: acquire a token

```python
import os
import requests # or any HTTP client -- this package does not send your request
from fuzefront_service_auth import ServiceAuthClient, TokenRequestError

client = ServiceAuthClient(
base_url="http://fuzefront-security-service:3000", # Kubernetes Service DNS
client_id=os.environ["FUZEFRONT_CLIENT_ID"],
client_secret=os.environ["FUZEFRONT_CLIENT_SECRET"],
)

try:
token = client.get_token()
except TokenRequestError:
# Fail closed: never send the request unauthenticated.
raise

response = requests.get(
"http://other-service/internal/reports",
headers={"Authorization": token.authorization_header},
)
```

`get_token()` caches the token in memory and only calls
`POST /api/v1/security/tokens` again shortly before the cached token expires
(a configurable safety margin, default 30s). Concurrent callers that land
during a refresh share the SAME in-flight HTTP request ("single-flight")
instead of each hammering the identity provider. Call `client.invalidate()`
to force the next `get_token()` to fetch a fresh token immediately (e.g.
after an unexpected 401 from a downstream call).

Every exception raised by this package is a `ServiceAuthError` (or a
subclass) carrying a stable `.code` and a suggested `.status`, using the
SAME code vocabulary as the TypeScript sibling's `ServiceAuthErrorCode`
(`MISCONFIGURED`, `TOKEN_REQUEST_FAILED`, `MALFORMED_RESPONSE`, `NO_TOKEN`,
`INTROSPECTION_UNAVAILABLE`, `TOKEN_INACTIVE`, `FORBIDDEN`, `UNKNOWN`) — the
FastAPI/Flask middleware below emits the same `{"error", "code"}` JSON body
shape the Express middleware does.

## Callee side: verify a token + framework middleware

```python
from fuzefront_service_auth import MachineTokenVerifier

verifier = MachineTokenVerifier(base_url="http://fuzefront-security-service:3000")

identity = verifier.verify_machine_token(bearer_token) # raises TokenVerificationError on ANY failure
print(identity.subject, identity.scope, identity.tenant_id)
```

**`verify_machine_token` fails closed on every ambiguity** -- a connection
error, a timeout, a malformed body, a body missing a boolean `active` field,
or `active: false` all raise the SAME `TokenVerificationError`. This matters
because FuzeFront's introspection endpoint **always answers HTTP 200** and
expresses the fail-closed decision purely in the body:

```ts
// backend/security/src/routes/security.ts (the actual server route)
router.post('/tokens/introspect', async (req, res) => {
try {
const r = await getIdentityProvider().introspectToken(req.body.token)
res.status(200).json(r)
} catch (err) {
res.status(200).json({ active: false }) // still 200!
}
})
```

A verifier that checks `response.status_code == 200` and stops there will
authenticate a revoked, expired, or unknown token. This package never
branches on status code alone -- see
`tests/test_verifier.py::test_inactive_token_with_http_200_is_rejected` for
the regression test that proves it.

### FastAPI

```bash
pip install "fuzefront-service-auth[fastapi]"
```

```python
from fastapi import Depends, FastAPI
from fuzefront_service_auth import MachineIdentity, MachineTokenVerifier
from fuzefront_service_auth.middleware.fastapi import machine_identity_dependency

verifier = MachineTokenVerifier(base_url="http://fuzefront-security-service:3000")
require_machine_identity = machine_identity_dependency(verifier)

app = FastAPI()

@app.get("/internal/reports")
async def reports(identity: MachineIdentity = Depends(require_machine_identity)):
return {"caller": identity.subject}
```

### Flask

```bash
pip install "fuzefront-service-auth[flask]"
```

```python
from flask import Flask, g
from fuzefront_service_auth import MachineTokenVerifier
from fuzefront_service_auth.middleware.flask import require_machine_identity

verifier = MachineTokenVerifier(base_url="http://fuzefront-security-service:3000")
require_identity = require_machine_identity(verifier)

app = Flask(__name__)

@app.route("/internal/reports")
@require_identity
def reports():
return {"caller": g.machine_identity.subject}
```

Both middlewares reject a missing/malformed `Authorization` header and any
token that fails verification with `401`, and attach the verified
`MachineIdentity` to the request (`request.state.machine_identity` /
`flask.g.machine_identity`).

## Authorization seam (for when `/authz/*` goes live)

Verifying a token proves **who** is calling; it says nothing about **what**
they may do. Both middlewares accept an optional `authorize` hook, called
with the verified `MachineIdentity` after authentication succeeds -- raise
`AuthorizationError` to deny (mapped to HTTP `403`):

```python
from fuzefront_service_auth import AuthorizationError, MachineIdentity

def check_authz(identity: MachineIdentity) -> None:
# Wire this to POST /api/v1/security/authz/check (or bulk-check) once
# those routes are live -- this package does not call them itself, since
# the request shape (single vs. bulk, which claim maps to which subject)
# is a decision for the calling service, not this library.
if not authz_client.check(identity.subject, resource="orders", action="read"):
raise AuthorizationError(f"{identity.subject} may not read orders")

require_identity = machine_identity_dependency(verifier, authorize=check_authz)
```

See [`authz.py`](src/fuzefront_service_auth/authz.py) for the full seam
documentation.

## Caching

- **Caller side**: the acquired access token is cached and refreshed before
its `expiresIn` lapses (with a safety margin) -- never one HTTP call per
outbound request.
- **Callee side**: only POSITIVE (`active: true`) introspection results are
cached, bounded by size (LRU) and capped at the token's own `expiresAt`
claim (never longer). A failed/inactive result is **never** cached at any
TTL -- caching it, even briefly, would let a just-revoked token continue
authenticating for the cache's lifetime.

## Development

```bash
pip install -e ".[dev]"
pytest -q
```

## Delivered by

FuzeFront's S2S auth foundation (PR #837 shipped `@fuzefront/security-client`
as generated types only, with no runtime for either TypeScript or Python).
This package is the Python runtime half; `packages/service-auth` is its
TypeScript sibling. Both are projections of one frozen contract -- neither is
a second source of truth.
31 changes: 31 additions & 0 deletions packages/service-auth-py/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "fuzefront-service-auth"
version = "1.0.0"
description = "Python service-to-service auth client + verifier + framework middleware for the FuzeFront Security API (M2M token issuance/introspection)"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "UNLICENSED" }
# Core client/verifier are deliberately dependency-free (stdlib urllib only) so
# this installs into any Python microservice regardless of web framework.
dependencies = []

[project.optional-dependencies]
fastapi = ["fastapi>=0.100", "starlette>=0.27"]
flask = ["flask>=2.3"]
dev = [
"pytest>=8.0",
"fastapi>=0.100",
"httpx>=0.24",
"flask>=2.3",
]

[tool.setuptools.packages.find]
where = ["src"]
include = ["fuzefront_service_auth*"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Loading
Loading