Skip to main content

masks for Python

masks-client connects a Python web app, service, or machine to masks. It signs people in, gets tokens as a client, and checks them as an API. Every call has a synchronous form and an async one.

pip install "masks-client @ git+https://github.com/masksrb/masks@python-v0.1.0#subdirectory=python"

The package installs from its release tag and is not on PyPI yet. It imports as masks and needs Python 3.10 or later.

Covers
Discovery The issuer's document, checked against the address it was fetched from.
Registration Redeeming an initial access token, then reading, updating, and deleting the client.
Enrollment An app asking for initial access tokens for its own machines, and unenrolling them.
Client authentication client_secret_basic, client_secret_post, private_key_jwt, and none.
Grants Client credentials, refresh tokens, the device flow, and token exchange.
DPoP Tokens held to a key, and proofs on every request an httpx client sends.
Revocation Access and refresh tokens.
Introspection Asking the issuer whether a token is still active.
Checking tokens at+jwt access tokens, including DPoP-bound ones, in any framework or as ASGI middleware.

masks.fastapi, masks.django, and masks.flask sign people in to a web app. See Python apps for the walkthrough and masks-client for every setting and call.

A machine signs in as itself

A machine keeps two P-256 keys. One signs its client assertions and the other holds its tokens, so a token copied off the machine is useless without the key.

import json
import masks

key = masks.Key.generate()
dpop = masks.Key.generate()

saved = json.dumps({"key": key.to_jwk(), "dpop": dpop.to_jwk()})
key = masks.Key.from_jwk(json.loads(saved)["key"])

to_jwk() writes the private key, so keep the file readable only by the machine's service user. key.jwks() is the public half to register.

issuer = masks.discover("https://auth.example.com")

client = masks.Client(issuer, client_id, key=key, dpop=dpop)
token = client.client_credentials(scope=["files:read"], resource=["https://files.example.com"])

The client authenticates with client_secret_basic when secret is set and with private_key_jwt when key is. Pass method to choose another. When dpop is set, a token that comes back unbound is refused.

Enrolling with an initial access token

A manager approves the client in masks, and masks gives out an initial access token for it. The machine redeems the token once and keeps what comes back.

registration = masks.Client.register(issuer, masks.ClientMetadata(), initial_access_token)
client = masks.Client.from_registration(issuer, registration, key=key, dpop=dpop)

read_registration, update_registration, and delete_registration manage the client with its registration access token. They send that token only to the issuer's own origin.

Enrolling machines as an app

An approved app that has claimed a namespace enrolls machines of its own, such as an agent it installs on each computer. It asks the issuer's enrollment_endpoint for an initial access token and hands the token to the machine, which registers with it as above.

app = masks.Client(issuer, app_id, secret=app_secret)

enrollment = app.enroll(scope=["xixo:agent"], client_name="nas", expires_in=900)
enrollment.initial_access_token
enrollment.expires_at

The app authenticates the way it does at the token endpoint. Every scope must fall under one of the app's namespaces. expires_in is between 60 and 3600 seconds and defaults to 15 minutes. The token is spent the first time a machine registers with it.

app.unenroll(machine_client_id)

unenroll archives the machine and revokes every token it holds. Unenrolling it again succeeds as well. A client the app did not enroll raises masks.Error with status 404.

Calling an API

client.auth returns an httpx.Auth that fetches and reuses tokens and signs each request. It sends tokens only to the origins it is given, and httpx drops the token when a redirect leaves that origin.

import httpx

source = client.credentials_source(scope=["files:read"], resource=["https://files.example.com"])

with httpx.Client(auth=client.auth(source, "https://files.example.com")) as api:
    response = api.get("https://files.example.com/files")

A held token goes out as Authorization: DPoP with a fresh proof, and a server that asks for a DPoP nonce is answered once. refresh_source renews a token with its refresh token and hands each rotated token to keep to be saved.

Exchanging a token

exchange trades a token for a new one under RFC 8693. A service that receives a caller's access token exchanges it for a token to another API, acting on the caller's behalf.

token = client.exchange(
    subject_token,
    scope=["files:read"],
    resource=["https://files.example.com"],
    requested_lifetime=300,
)

The subject is an access token unless subject_token_type says masks.ID_TOKEN_TYPE. actor_token names another party in the token's act claim. The new token lasts no longer than the subject token. When the client has a dpop key the new token is held to it, and a subject token held to a key is exchanged only with a proof from that same key.

requested_token_type=masks.UPSTREAM_ACCESS_TOKEN_TYPE with a connection's id in audience releases the access token masks holds for that connection through a delegation. That token is a Bearer token from the provider, so it comes back unbound with issued_token_type naming the upstream type.

Async

masks.AsyncClient has the same methods as masks.Client, awaited, and masks.adiscover discovers an issuer.

issuer = await masks.adiscover("https://auth.example.com")

async with masks.AsyncClient(issuer, client_id, key=key, dpop=dpop) as client:
    source = client.credentials_source(resource=["https://files.example.com"])

    async with httpx.AsyncClient(auth=client.auth(source, "https://files.example.com")) as api:
        response = await api.get("https://files.example.com/files")

The device flow

A machine with no browser shows a code that a person approves on another device.

device = client.device_authorization(scope=["openid", "offline_access"])
print(f"Open {device.verification_uri} and enter {device.user_code}")

token = client.await_device(device)

await_device polls at the interval masks names, slows down when asked, and stops when the code expires. The synchronous form takes a timeout in seconds, and the async form stops when its task is cancelled.

Checking access tokens

masks.Verifier checks a token from any framework. Pass the method, the URL the client used, and the request's headers.

verifier = masks.Verifier(issuer, "https://files.example.com", public_url="https://files.example.com")

claims = verifier.authenticate(request.method, request.url, request.headers)
claims.has_scope("files:read")

The verifier checks the signature against the issuer's published keys, the at+jwt type, the issuer, the audience, and the expiry. A DPoP-bound token needs a proof signed by its key, made for this method and URL, made in the last minute, and not seen before. A failed check raises masks.InvalidToken.

When the verifier cannot reach the issuer's keys, or its replay store fails, it raises masks.Unavailable. The token may still be good, so answer 503 and let the client try again with the same token.

Set public_url when a proxy sits in front of the API, since a proof names the address the client used.

Replayed proofs

A verifier keeps the proofs it has seen in the process's memory. An API that runs in several processes passes replay an object whose first(key, expires_in) answers True only the first time it sees a key across every process, and False after that, for expires_in seconds.

class RedisReplay:
    def __init__(self, redis):
        self.redis = redis

    def first(self, key, expires_in):
        return bool(self.redis.set(f"dpop:{key}", 1, nx=True, ex=expires_in))


verifier = masks.Verifier(issuer, "https://files.example.com", replay=RedisReplay(redis))

AsyncVerifier also takes a store whose first is a coroutine, and Verifier raises TypeError when given one. An answer of False raises masks.InvalidToken. When the store raises or answers anything other than True or False, the verifier raises masks.Unavailable from the store's error. masks.MemoryReplayStore is the default, and one instance can be shared between verifiers in the same process.

Introspection

A verifier that checks the JWT sees a revoked token as valid until it expires. introspect asks the issuer.

found = client.introspect(token, "access_token")
found.active and found.has_scope("files:read")

The issuer answers active only to the client the token was issued to, or to an approved client whose resources include one of the token's audiences. A token that is not active has no scopes. An answer that names another issuer raises masks.Error.

ASGI and FastAPI

masks.asgi.Middleware checks every request with an AsyncVerifier and answers 401 with WWW-Authenticate challenges when the check fails. It answers 503 with no challenge when the verifier raises masks.Unavailable. The claims are at request.state.masks_claims.

from fastapi import Depends, FastAPI, HTTPException, Request
from masks.asgi import Middleware

verifier = masks.AsyncVerifier(issuer, "https://files.example.com", public_url="https://files.example.com")

app = FastAPI()
app.add_middleware(Middleware, verifier=verifier)


def reading(request: Request) -> masks.Claims:
    claims = request.state.masks_claims

    if not claims.has_scope("files:read"):
        raise HTTPException(403)

    return claims


@app.get("/files")
async def files(claims: masks.Claims = Depends(reading)):
    return {"for": claims.subject}

Django and Flask

Call Verifier.authenticate in a view decorator or a before_request hook. In Django, pass request.method, request.build_absolute_uri(), and request.headers. In Flask, pass request.method, request.url, and request.headers. Answer 401 when it raises masks.InvalidToken, and 503 when it raises masks.Unavailable.

Plain HTTP

Every URL is https, except on this machine: localhost, a .localhost name, or a loopback address. allow_plain_http=True on discover lifts that for a private network.

Metadata

Release files for masks-client 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for masks-client 0.2.0
File Size Uploaded Provenance
masks_client-0.2.0.tar.gz 153.2 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for masks-client 0.2.0
File Interpreter ABI Platform
masks_client-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 215.8 kB

Release files / masks_client-0.2.0.tar.gz

Download URL masks_client-0.2.0.tar.gz
Size 153.2 kB
Tags Source
SHA-256 checksum
How to use checksums
89b3b18d8b221a72f6bd8a3c764a7d4fc760b47319ea9d47c5560fa0431c39a2
BLAKE2b-256 checksum
How to use checksums
f65eb1f87600502fe21dd17e627ecbf7499c3fbbdf249c427c137761736dbb26
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.

Transparency log

Release files / masks_client-0.2.0-py3-none-any.whl

Download URL masks_client-0.2.0-py3-none-any.whl
Size 62.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1207c49f6c6bb00d6271fa71ffe04beeda4c4f026caf4092529249a4a371a9bc
BLAKE2b-256 checksum
How to use checksums
70dd0658b4a6d444da2388646a7f8856987490c96911be8d86914c2049ef105f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.

Transparency log

Security for masks-client 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for masks_client-0.2.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page