Skip to content
Open
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
88 changes: 79 additions & 9 deletions designs/DEVICE_AUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
> `set_grant_revocation_check`). Wired in `omnigent/server/app.py`,
> **opt-in and default-off** via `OMNIGENT_DEVICE_GRANT_ENABLED` (the
> `/oauth/*` routes are unmounted unless it is truthy), and then only in
> **accounts** mode (OIDC delegates login to the IdP via the cli-ticket
> flow and never mounts these routes).
> **accounts** and **oidc** modes — the two that own a server-minted
> session cookie. Header mode has no server-mintable identity.
> Slack: `integrations/slack/src/omnigent_slack/oauth.py`,
> `tokens.py` (Fernet-encrypted `oauth_tokens`), `auth_manager.py`, plus
> the bearer/refresh wiring in `omnigent.py` (`ClientAuth`,
Expand All @@ -38,6 +38,14 @@
> (`app.py`: device auth is `oidc`/`accounts` only), so `start_login`
> raises a clear error rather than firing a request the server would 404.
>
> **Note.** Mounting the grant under OIDC does not change either
> first-party client: the CLI (`cli.py`) and Slack (`oauth.py`) still
> probe the mode and still take the cli-ticket flow there. The OIDC
> device-grant routes exist for *external* clients — ones that must hold a
> scoped, revocable, refreshable credential rather than the server's own
> session JWT, and that cannot be trusted with account-wide authority.
> Migrating the first-party clients onto it is separate work.
>
> Tests: `tests/server/test_device_auth.py`, and the Slack
> `test_oauth.py` / `test_tokens.py` / `test_client_auth.py` /
> `test_auth_manager.py`.
Expand Down Expand Up @@ -91,9 +99,11 @@ The device grant builds on existing server primitives:
- **Bearer validation** — `UnifiedAuthProvider._check_cookie` accepts
`Authorization: Bearer <jwt>` and validates the same claim shape
(`auth.py`). Delegated access tokens validate through this path unchanged.
- **Browser consent under accounts mode** — the `accounts` provider
establishes the browser identity via its session cookie; the consent page
runs behind it. (This is why the grant mounts in accounts mode only — see
- **Browser consent** — both `accounts` and `oidc` establish the browser
identity through the same HS256 session cookie, and the consent page runs
behind it. Under OIDC the bounce hands off to the IdP, so *how* the user
proves themselves — password, Google, SAML — is never this module's
business. (Header mode mints no session, which is why it is excluded — see
the mount restriction below.)
- **Open-redirect hardening** — `_sanitize_return_to` (`routes/auth.py`) guards
the post-login bounce back to the consent page.
Expand Down Expand Up @@ -184,10 +194,70 @@ approved it.

Mounted in `app.py` only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy**
(opt-in, **default-off** — the `/oauth/*` routes are absent otherwise), and
then **only in `accounts` mode** (OIDC delegates login to the IdP via the
cli-ticket flow and never mounts these routes; header mode has no
server-mintable identity — see `create_device_auth_router`, which raises if
constructed for any other source). The `device_grants` table is created
then **only in `accounts` and `oidc` modes**. Header mode has no
server-mintable identity — there is nothing to delegate from and no login to
bounce a consenting browser through — see `create_device_auth_router`, which
raises if constructed for any other source.

**Forced re-authentication across the two modes.** The consent gate (session
`iat` ≥ grant `created_at`) bounces with `reauth=1`, and each login path has
to honour it in its own way. Accounts holds back the SPA's auto-redirect and
demands a password. OIDC has no form to hold back, so `/auth/login` forwards
`reauth=1` to the IdP as `prompt=login` (OIDC Core 3.1.2.1). Dropping that
forwarding does not break the flow — the IdP satisfies the bounce from its own
session and the callback mints a fresh `iat` that clears the gate — it just
silently removes the deliberate-credential-entry property the gate exists for.
Pinned by `tests/server/test_oidc_reauth_prompt.py`.

*Every* bounce is forced, including the one for a caller with no Omnigent
session. Under accounts that changes nothing — no session means no credential,
so the form is shown regardless — but under OIDC the caller may still hold a
live IdP session, and the consent page cannot tell the two cases apart.

**Requested, then verified.** `prompt=login` is only a request, so the bounce
also sends `max_age=0`, which obliges a conforming IdP to report the moment it
authenticated the user in the `auth_time` claim. The callback compares that
claim against the id_token's own `iat` — both the IdP's clock, so skew between
the two servers cancels out and a session established minutes earlier fails the
comparison regardless of whose clock is ahead. A missing `auth_time` fails too:
silence is indistinguishable from a reused session.

**The proof rides on the session, not on `iat`.** Gating consent on the session
cookie's `iat` was bypassable without ever attacking the gate. `/auth/login` is
a public GET accepting any same-origin `return_to`, so an attacker who starts a
grant can send the victim `/auth/login?return_to=/oauth/device?user_code=…`
with **no** `reauth=1` — no `reauth_at` is signed, nothing is demanded, the IdP
satisfies it from its own session, and the resulting cookie carries an `iat` of
*now* that clears the gate. The forced path was never entered.

So a successful re-authentication is recorded on the session itself, as an
`auth_time` claim (`mint_session_token`), written **only** where a credential
was actually presented: an accounts password submit, or an IdP-attested
re-authentication. Consent requires `auth_time ≥ grant.created_at`. A login
that skipped the bounce carries no claim, so it bounces and is made to prove
itself — the demand no longer depends on the attacker's link having asked for
it. `reauth_at` remains, signed into the state cookie, as the marker that makes
the callback *refuse* (403, no session minted) rather than merely decline to
stamp the proof.

An IdP that never emits `auth_time` cannot carry a device grant: consent
bounces once, the forced callback 403s with a clear error, and the loop
terminates rather than spinning.

**Which providers qualify.** `unsupported_reason` **allowlists**
`provider_type == "oidc"` rather than denying known-bad values. `from_env`
yields only `github` or `oidc` today, so the two are equivalent right now — but
the next OAuth 2.0 dialect modelled under the `oidc` source would otherwise be
admitted by default, behind a gate that cannot hold for it. GitHub is the
present example: `from_env` points it at
`https://github.com/login/oauth/authorize`, which has no `prompt` parameter, no
id_token and no `auth_time`, so there is no way to demand a re-authentication
nor to detect that none happened.

`app.py` logs every refusal, and computes it **before** the auth-router mount —
that mount is gated on `login_url` being truthy, and header mode's is `None`,
so an operator in the one mode where the grant can never work was previously
the only one who never saw the explanation. The `device_grants` table is created
unconditionally by the migration regardless of the flag; only the router
mount is gated. This router **owns** `mint_delegated_token` and
`DELEGATED_SCOPE`.
Expand Down
39 changes: 31 additions & 8 deletions omnigent/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2349,6 +2349,30 @@ async def _on_hosts_changed(_host_id: str, owner: str | None) -> None:
# /magic/redeem, /users, /users/{id}/reset, /users/me/password).
# Must be registered BEFORE the SPA static mount because the SPA's
# HTML5-history fallback catches all unmatched extensionless paths.
# Device-grant support is decided BEFORE the auth-router mount below,
# which is gated on `login_url` being truthy — and header mode's is None.
# Computing it inside that block meant the one operator most likely to be
# confused by a missing /oauth/* (header mode, where the grant can never
# work) was the one who never saw the explanation.
from omnigent.server.auth import UnifiedAuthProvider as _UnifiedAuthProvider
from omnigent.server.auth import env_var_is_truthy
from omnigent.server.routes.device_auth import unsupported_reason

_device_grant_wanted = env_var_is_truthy("OMNIGENT_DEVICE_GRANT_ENABLED", default=False)
_device_grant_blocked = (
unsupported_reason(auth_provider)
if isinstance(auth_provider, _UnifiedAuthProvider)
else "a custom auth provider cannot mint the session the grant delegates from"
)
if _device_grant_wanted and _device_grant_blocked is not None:
# Asked for and refused: say so, or the operator sees only the absence
# of /oauth/* and assumes the flag did not take.
_logger.warning(
"device-grant: OMNIGENT_DEVICE_GRANT_ENABLED is set but the "
"/oauth/* routes are NOT mounted — %s. See designs/DEVICE_AUTH.md.",
_device_grant_blocked,
)

if auth_provider is not None and getattr(auth_provider, "login_url", None):
from omnigent.server.auth import UnifiedAuthProvider

Expand Down Expand Up @@ -2409,17 +2433,16 @@ async def _on_hosts_changed(_host_id: str, owner: str | None) -> None:
)

# Device Authorization Grant (RFC 8628): opt-in, default-off via
# OMNIGENT_DEVICE_GRANT_ENABLED, and accounts-mode only. OIDC delegates
# login to the IdP (cli-ticket flow), so it neither needs nor mounts
# these routes. Wires the revocation lookup into the auth provider so
# revoking a grant immediately rejects its delegated access tokens.
# OMNIGENT_DEVICE_GRANT_ENABLED, and only for providers that can be
# made to re-prompt an already signed-in user — see
# `unsupported_reason`, which owns that rule. Wires the revocation
# lookup into the auth provider so revoking a grant immediately
# rejects its delegated access tokens.
# See designs/DEVICE_AUTH.md.
from omnigent.server.auth import env_var_is_truthy

if (
env_var_is_truthy("OMNIGENT_DEVICE_GRANT_ENABLED", default=False)
_device_grant_wanted
and isinstance(auth_provider, UnifiedAuthProvider)
and auth_provider._source == "accounts"
and _device_grant_blocked is None
and permission_store is not None
):
from omnigent.server.device_grant_store import DeviceGrantStore
Expand Down
24 changes: 22 additions & 2 deletions omnigent/server/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ def mint_session_token(
cookie_secret: bytes,
ttl_seconds: int,
provider: str,
*,
auth_time: int | None = None,
) -> str:
"""
Mint a signed session JWT with a second-granularity lifetime.
Expand All @@ -65,21 +67,34 @@ def mint_session_token(
same validator (:meth:`UnifiedAuthProvider._check_cookie`) accepts
either.

``auth_time`` records **when this server last saw the user prove who
they are**, on this server's clock. It is deliberately not the same as
``iat``: minting a session proves only that a login flow completed,
which under OIDC can happen with no user involvement at all when the
IdP reuses its own session. Callers pass it only where a credential
was actually presented — a password submit, or an IdP-attested
re-authentication. Absent otherwise, and consumers must fail closed on
its absence. See :mod:`omnigent.server.routes.device_auth`.

:param user_id: The authenticated user's email, e.g.
``"alice@example.com"``.
:param cookie_secret: HMAC key for HS256 signing.
:param ttl_seconds: Token lifetime in seconds.
:param provider: Identity provider name, e.g. ``"google"`` or
``"accounts"``. Stored as an informational claim.
:param auth_time: Epoch seconds (this server's clock) at which the
user proved their identity, or ``None`` when unproven.
:returns: An HS256-signed JWT string.
"""
now = int(time.time())
payload = {
payload: dict[str, object] = {
"sub": user_id,
"iat": now,
"exp": now + ttl_seconds,
"provider": provider,
}
if auth_time is not None:
payload["auth_time"] = auth_time
return jwt.encode(payload, cookie_secret, algorithm="HS256")


Expand All @@ -88,6 +103,8 @@ def mint_session_cookie(
cookie_secret: bytes,
ttl_hours: int,
provider: str,
*,
auth_time: int | None = None,
) -> str:
"""Mint a signed session cookie JWT.

Expand All @@ -97,9 +114,12 @@ def mint_session_cookie(
:param ttl_hours: Session lifetime in hours.
:param provider: Identity provider name, e.g. ``"google"``
or ``"github"``. Stored as an informational claim.
:param auth_time: See :func:`mint_session_token`.
:returns: An HS256-signed JWT string.
"""
return mint_session_token(user_id, cookie_secret, ttl_hours * 3600, provider)
return mint_session_token(
user_id, cookie_secret, ttl_hours * 3600, provider, auth_time=auth_time
)


def hmac_digest(token: str, secret: bytes) -> str:
Expand Down
12 changes: 12 additions & 0 deletions omnigent/server/routes/accounts_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,9 @@ async def login(body: LoginRequest) -> Response:
cookie_secret=config.cookie_secret,
ttl_hours=config.session_ttl_hours,
provider="accounts",
# The server verified the credential itself just now, so this is
# a local fact rather than a third party's claim about one.
auth_time=int(time.time()),
)

user = account_store.get_user(username)
Expand Down Expand Up @@ -446,6 +449,9 @@ async def register(body: RegisterRequest) -> Response:
cookie_secret=config.cookie_secret,
ttl_hours=config.session_ttl_hours,
provider="accounts",
# The server verified the credential itself just now, so this is
# a local fact rather than a third party's claim about one.
auth_time=int(time.time()),
)
resp = JSONResponse(
status_code=200,
Expand Down Expand Up @@ -545,6 +551,9 @@ async def setup(body: SetupRequest) -> Response:
cookie_secret=config.cookie_secret,
ttl_hours=config.session_ttl_hours,
provider="accounts",
# The server verified the credential itself just now, so this is
# a local fact rather than a third party's claim about one.
auth_time=int(time.time()),
)
resp = JSONResponse(
status_code=200,
Expand Down Expand Up @@ -631,6 +640,9 @@ async def magic_redeem(request: Request) -> Response:
cookie_secret=config.cookie_secret,
ttl_hours=config.session_ttl_hours,
provider="accounts",
# The server verified the credential itself just now, so this is
# a local fact rather than a third party's claim about one.
auth_time=int(time.time()),
)
resp = RedirectResponse(url="/", status_code=302)
_set_session_cookie(
Expand Down
Loading
Loading