Skip to content

woodglue.apps.authn

Request authentication: bearer tokens and the Session cookie.

Request authentication shared by the woodglue HTTP handlers.

authenticate turns a request into a woodglue.access.Caller, reading the application settings auth_enabled and auth_store. An app with auth disabled serves every request as the anonymous Caller.

A request authenticates in one of these ways, tried in order:

  • Authorization: Bearer <token>, for the CLI, scripts, and LLM tooling.
  • An identity asserted by a trusted reverse proxy (see woodglue.apps.identity_providers.TrustedProxy). The first valid assertion signs the person in and sets a Session cookie; later requests reuse that Session while the proxy keeps asserting the same person.
  • The wgl_session cookie, set by /auth/login for browsers. It is HttpOnly, so no page script (including a project UI) can read it (see docs/adr/0003).

The last two arrive automatically with any browser request, so both count as cookie authentication, and state-changing handlers must call is_same_origin for them.

Tokens in query strings are not accepted, since URLs end up in logs, browser history, and Referer headers.

Authentication dataclass

The Caller of a request (None if unauthenticated) and how it authenticated.

Source code in src/woodglue/apps/authn.py
@dataclass(frozen=True)
class Authentication:
    """The Caller of a request (`None` if unauthenticated) and how it authenticated."""

    caller: Caller | None
    via: AuthVia
    session_secret: str | None = None

bearer_token(request)

The token of an Authorization: Bearer header, or "".

Source code in src/woodglue/apps/authn.py
def bearer_token(request: tornado.httputil.HTTPServerRequest) -> str:
    """The token of an `Authorization: Bearer` header, or `""`."""
    header = request.headers.get("Authorization", "")
    return header[7:].strip() if header.startswith("Bearer ") else ""

authenticate(handler)

Who sent this request and how, trying the ways above in order.

Source code in src/woodglue/apps/authn.py
def authenticate(handler: tornado.web.RequestHandler) -> Authentication:
    """Who sent this request and how, trying the ways above in order."""
    settings = handler.application.settings
    if not settings.get("auth_enabled", False):
        return Authentication(ANONYMOUS, AuthVia.anonymous)
    store: AuthStore | None = settings.get("auth_store")
    if store is None:
        return Authentication(None, AuthVia.anonymous)
    token = bearer_token(handler.request)
    if token:
        return Authentication(store.resolve(token), AuthVia.bearer)
    proxied = _proxy_authentication(handler, store)
    if proxied is not None:
        return proxied
    secret = handler.get_cookie(SESSION_COOKIE)
    if secret:
        return Authentication(store.resolve(secret), AuthVia.cookie, secret)
    return Authentication(None, AuthVia.anonymous)

is_same_origin(request)

Whether a browser request comes from this server's own pages. Uses Origin, falling back to Referer; a request with neither is not a browser cross-site request and passes.

Source code in src/woodglue/apps/authn.py
def is_same_origin(request: tornado.httputil.HTTPServerRequest) -> bool:
    """
    Whether a browser request comes from this server's own pages. Uses `Origin`,
    falling back to `Referer`; a request with neither is not a browser
    cross-site request and passes.
    """
    source = request.headers.get("Origin") or request.headers.get("Referer")
    if source is None:
        return True
    return urlsplit(source).netloc == request.host

public_scheme(handler)

The scheme browsers use to reach this server: that of auth.public_url when set, since a proxy terminating TLS forwards plain HTTP, else the request's own.

Source code in src/woodglue/apps/authn.py
def public_scheme(handler: tornado.web.RequestHandler) -> str:
    """
    The scheme browsers use to reach this server: that of `auth.public_url`
    when set, since a proxy terminating TLS forwards plain HTTP, else the
    request's own.
    """
    config = handler.application.settings.get("config")
    public_url = config.auth.public_url if config is not None else None
    return urlsplit(public_url).scheme if public_url else handler.request.protocol