Skip to content

feat(auth): mount the device grant under OIDC, and keep its re-auth gate honest - #4304

Open
appletechie wants to merge 5 commits into
omnigent-ai:mainfrom
appletechie:feat/device-grant-oidc
Open

feat(auth): mount the device grant under OIDC, and keep its re-auth gate honest#4304
appletechie wants to merge 5 commits into
omnigent-ai:mainfrom
appletechie:feat/device-grant-oidc

Conversation

@appletechie

Copy link
Copy Markdown
Contributor

Related issue

N/A — found while connecting a delegated third-party client to a self-hosted
OIDC deployment.

Summary

The device-authorization grant (RFC 8628) refuses to mount unless auth mode is
accounts. OIDC deployments are excluded on the reasoning that they delegate
login to the IdP via the cli-ticket flow and so never need it.

That reasoning covers a CLI logging a human in. It does not cover a
third-party application asking to act as a user
, which is what the grant is
for. The cli-ticket flow hands back the server's own session JWT: full account
authority, no scope claim, no grant_id, no revocation handle, no act
provenance. Everything that makes a delegated token safe to hand to an
application is exactly what it lacks. So an OIDC deployment has no way to
authorize an application at all.

The grant needs nothing from accounts that oidc does not also provide —
both mint the same HS256 session cookie, and _check_cookie already picks
between the two configs the same way this now does.

header mode stays excluded for a real reason rather than symmetry: identity
there is asserted by an upstream proxy, so there is no session to delegate
from and no login to bounce a consenting browser through.

The part that isn't a gate flip

Consent requires a login performed after the grant began (session iat
grant created_at); a stale session bounces to the login page with reauth=1.
That gate is the anti-phishing control — a victim handed a one-click link must
re-authenticate before they can approve anything.

Under OIDC that bounce was silently a no-op:

stale session → bounce /auth/login?reauth=1
   → IdP recognises its own session
   → silent redirect back
   → callback mints session with fresh iat
   → gate satisfied

The user proves nothing. Nothing errors, no test fails, and the control is gone
while still appearing to be there. It would have shipped green — which is the
part worth flagging.

/auth/login now forwards reauth=1 to the IdP as prompt=login
(OIDC Core 3.1.2.1), the standard way to ask an IdP to re-prompt a user it has
already authenticated. Two deliberate narrownesses:

  • Only on that path. Unconditional prompt=login would cost a password on
    every ordinary sign-in, which is how a control like this ends up switched off
    by whoever finds it irritating.
  • Only exact "1". The consent page is the only caller and sends exactly
    that; accepting loose truthy spellings would widen the surface for an
    unrelated query param to trigger a re-prompt.

Changes

File
server/app.py mount gate accepts oidc; warns when the flag is set but the mode can't support it
server/routes/device_auth.py factory accepts oidc, selects _oidc_config
server/routes/auth.py reauth=1prompt=login
server/oidc.py, routes/accounts_auth.py supporting wiring
designs/DEVICE_AUTH.md mode claims + the cross-mode re-auth note
tests/server/test_device_auth.py mode test split: header rejected, oidc builds
tests/server/test_oidc_reauth_prompt.py new — pins the parameter
tests/server/test_oidc_callback.py callback coverage for the fresh-iat path

Test Plan

  • pytest tests/server/test_device_auth.py tests/server/test_oidc_callback.py tests/server/test_oidc_reauth_prompt.py
    73 passed.
  • pytest tests/server -k "auth or oidc or device"321 passed, covering
    device_auth, oidc, callback, invites, open-redirect and accounts alongside the
    new file.
  • The tests are not vacuous: removing the prompt=login block fails the new
    suite.
  • return_to still round-trips the bounce, so a re-auth does not abandon the
    pending grant.
  • pre-commit run --files over every touched file.

Demo

N/A — no visual surface. The observable change is that an OIDC deployment can
authorize a third-party application at all, and that the consent bounce actually
forces a re-authentication instead of silently round-tripping.

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

The existing e2e browser proof (tests/e2e_ui/auth/test_device_grant_reauth.py)
stays accounts-only: it drives a real password form, which OIDC does not have.
The OIDC half is covered at the route level instead — the new suite asserts the
exact query parameter sent to the IdP, which is the only thing the server
controls on that path. Flagging that asymmetry explicitly rather than leaving it
to be discovered.

Changelog

OIDC deployments can authorize third-party applications through the device grant, and the consent re-authentication is actually enforced.

…ate honest

The device-authorization grant refused to mount unless auth mode was
`accounts`. OIDC deployments were excluded on the reasoning that they
delegate login to the IdP via the cli-ticket flow and so never need it.

That reasoning covers a CLI logging a human in. It does not cover a
third-party application asking to act as a user: the cli-ticket flow
hands back the server's own session JWT — full account authority, no
scope claim, no grant id, no revocation handle, no `act` provenance.
Everything that makes a delegated token safe to give away is exactly
what it lacks. An OIDC deployment therefore had no way to authorize an
application at all, and the device grant needs nothing from `accounts`
that `oidc` does not also provide: both mint the same HS256 session
cookie, and `_check_cookie` already picks between the two configs the
same way this now does.

Header mode stays excluded, and for a real reason rather than symmetry:
identity there is asserted by an upstream proxy, so there is no session
to delegate FROM and no login to bounce a consenting browser through.

## The part that is not a gate flip

Consent requires a login performed AFTER the grant began (session `iat`
>= grant `created_at`). A stale session is bounced to the login page with
`reauth=1`. That gate is the anti-phishing control: a victim handed a
one-click link with the code prefilled must deliberately re-enter their
credentials against a screen naming the identity and the client, rather
than approving by reflex.

`reauth=1` was implemented entirely in the accounts SPA login form, which
holds back its auto-redirect and demands a password. `/auth/login` never
read the parameter. So mounting the router under OIDC and stopping there
would have produced this: bounce to the IdP, IdP recognises its own
session, silent redirect back, callback mints a session with a fresh
`iat`, gate satisfied. The user proves nothing. Nothing errors, no test
fails, and the control is gone while still appearing to be there.

`/auth/login` now forwards `reauth=1` to the IdP as `prompt=login` (OIDC
Core 3.1.2.1) — the standard way to ask an IdP to re-prompt a user it has
already authenticated. Sent only on that path; unconditional re-prompting
would cost a password on every ordinary sign-in, which is how a control
like this ends up switched off by whoever finds it irritating.

Matched strictly against `"1"`. The consent page is the only caller and
sends exactly that, so accepting loose truthy spellings would only widen
the surface for an unrelated query param to trigger a re-prompt.

## Verified

- 220 auth-suite tests pass (device_auth, oidc, callback, invites,
  open-redirect, accounts, and the new file)
- `test_oidc_reauth_prompt.py` pins the parameter both ways: present on
  `reauth=1`, absent otherwise, and `return_to` still round-trips through
  the bounce so the pending grant is not abandoned
- Removing the `prompt=login` block fails that suite; the tests are not
  vacuous
- ruff, ruff format and mypy clean on every touched file

The e2e browser proof (`tests/e2e_ui/auth/test_device_grant_reauth.py`)
remains accounts-only — it drives a real password form, which OIDC does
not have. The OIDC half is covered at the route level above.

Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
…Auth

Addresses three review findings on the device-grant-under-OIDC change.
Both P1s are the same mistake as the one that change was written to fix:
a control that still looks present after it has stopped working.

## The unforced first bounce

`device_consent_page` bounced an unauthenticated caller with
`reauth=False`, forcing re-authentication only when an existing Omnigent
cookie was stale.

Under accounts that distinction is sound: no session means no credential,
so the SPA shows the password form either way. Under OIDC it is not.
"No Omnigent session" says nothing about the IdP's session, which is
separate and may well be live — so the unforced bounce is satisfied
silently, the callback mints a cookie with an `iat` newer than the grant,
and the consent gate passes without the user having proven anything. The
same silent-pass the `prompt=login` work closed, reached by the sibling
path.

The consent page cannot tell the two cases apart, so every bounce now
forces it. The parameter is gone rather than defaulted, because there is
no caller that wants an unforced bounce.

## GitHub OAuth cannot honour the gate

`OIDCConfig.from_env` accepts GitHub as an `oidc` source but points it at
`https://github.com/login/oauth/authorize` — plain OAuth 2.0, with no
`prompt` parameter in the specification. `prompt=login` is ignored,
GitHub reuses its session, and the fresh `iat` clears the gate. The
security property the whole change rests on is absent for that provider,
while every test and every log line reads as though it holds.

`unsupported_reason` now owns the rule and refuses the grant there. A
grant issued behind a gate that cannot hold is worse than no grant,
because it looks protected. `app.py` consults the same predicate and logs
the refusal, so an operator who set OMNIGENT_DEVICE_GRANT_ENABLED learns
why `/oauth/*` is missing rather than concluding the flag did not take —
and the server still boots.

Folding the header-mode check into the same predicate keeps one answer to
"can this provider carry a grant", rather than two that can drift.

## Comment length

The forced-reauth rationale in routes/auth.py ran ten inline lines
against AGENTS.md's three-line guidance. Condensed to three; the full
reasoning already lives in the route docstring and DEVICE_AUTH.md.

## Verified

- 231 tests pass across the auth suite and app integration
- Both P1 fixes mutation-checked: restoring the unforced bounce fails 1
  test, allowing GitHub fails 2
- New coverage: forced bounce with no session at all, GitHub refused,
  and `unsupported_reason` admitting standard OIDC and accounts so the
  predicate cannot over-refuse
- ruff, ruff format, mypy clean

Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
… for it

Addresses the remaining review findings on this branch. All three come
back to the same gap: the gate was requested but never checked.

## prompt=login was a request with no verification

`prompt=login` asks an IdP to re-authenticate. Nothing confirmed it
obeyed, and `auth_time` appeared nowhere in the codebase. A
non-conforming or misconfigured IdP satisfies the bounce from its own
session, the callback mints a cookie with a fresh `iat`, and the consent
gate passes — the same silent failure this branch exists to prevent, one
level further out.

The bounce now sends `max_age=0` alongside `prompt=login`, which obliges
a conforming IdP to return `auth_time`. `/auth/login` signs the bounce
time into the state cookie as `reauth_at`, so the requirement cannot be
stripped by editing the URL, and `/auth/callback` refuses with 403 —
minting no session — unless the returned `auth_time` postdates it.

A missing `auth_time` is refused as well. Silence is indistinguishable
from a reused session, and this is the only gate between a phished
consent link and a delegated grant. Ordinary logins carry no `reauth_at`
and are untouched; most IdPs omit the claim, and requiring it everywhere
would break every sign-in.

Verifying it meant reading the id_token twice, so the validated decode is
now `_verified_id_token_claims`, shared by the email resolver and the
freshness check. One validated path, so no caller can read a claim out of
an unverified token.

GitHub short-circuits `reauth` at the login route as well as being
refused the grant: it can neither be asked to re-authenticate nor report
that it did, and setting `reauth_at` for it would fail every such login
at the callback instead.

## The mount decision was never exercised through create_app

The factory tests prove the router builds; they never proved the app
calls it. A typo in the mount condition would leave `/oauth/*` absent
under OIDC with the whole suite green — which is exactly the failure the
condition was widened to avoid. `create_app` is now driven directly with
a real OIDC provider (constructed in-process, so no IdP discovery
request), asserting the grant is reachable, and that it is absent for
GitHub. The GitHub case asserts against the route table rather than a
status code: the SPA catch-all answers unmounted paths, so "not 200"
would also pass with the routes mounted and merely erroring.

## A SimpleNamespace config proved nothing about the real cookie

`_session_iat` reads `cookie_config.session_cookie_name` and verifies
with `cookie_config.cookie_secret`. If either diverged from what
`/auth/callback` sets, it would return None on every request and the
consent page would bounce forever — a login loop with no error and no
failing test. A hand-built stub cannot catch that. Added a test that
mints a session through the real `mint_session_cookie` from a real
`OIDCConfig` and asserts the consent page renders, naming the identity
and the client.

## Verified

- 280 tests pass across the auth suite, integration and e2e
- Mutation-checked: dropping the callback verification fails 2 tests,
  treating a missing auth_time as a pass fails 1
- ruff, ruff format, mypy clean

Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
Addresses the second review round. The High is the same class of hole as
the previous two: a control that reads as present while a path around it
stays open.

## The gate could be skipped rather than beaten

Consent required the session's `iat` to postdate the grant, on the
reasoning that only a login started for this flow could produce a fresh
one. It could not: `/auth/login` is a public GET accepting any
same-origin `return_to`, so an attacker who starts a grant sends the
victim

    /auth/login?return_to=%2Foauth%2Fdevice%3Fuser_code%3DXXXX

with no `reauth=1`. Nothing is demanded, the IdP satisfies it from its
own session, the callback mints a cookie with `iat` of now, and consent
renders. The forced-re-authentication path was never entered, so
hardening it changed nothing. The victim clicked one link.

`iat` cannot carry this property: every completed callback has a fresh
one, including the ones with no user involvement at all. So the proof is
now recorded on the session as an `auth_time` claim, written only where
a credential was actually presented — an accounts password submit, or an
IdP-attested re-authentication — and 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` stays as the marker that makes the callback 403 outright
rather than merely decline to stamp the proof. Both the consent GET and
the approve POST read the new claim, so neither can be reached by
posting directly.

An IdP that never emits `auth_time` now cannot carry a grant: one
bounce, then a 403 with a clear error. That terminates instead of
looping, and it is the correct answer for a provider that cannot support
the control.

## Freshness is compared on one clock

`auth_time >= reauth_at - 60` mixed the IdP's clock with ours, so a 60s
window admitted an authentication performed *before* the bounce that
demanded it. It now compares `auth_time` against the id_token's own
`iat` — both the IdP's — so skew between the two servers cancels out and
a reused session fails regardless of whose clock is ahead. The remaining
allowance covers the IdP's processing between authenticating the user
and signing the token, which is what it should have been measuring.

## Allowlist, not denylist

`unsupported_reason` denied `provider_type == "github"` and admitted
every other string, including a `None` config. `from_env` yields only
`github` or `oidc` today so nothing was broken, but the next OAuth
dialect modelled under this source would have been admitted by default,
behind a gate that cannot hold for it. Now only `"oidc"` qualifies, and
the refusal names the value it rejected.

## The refusal was invisible where it mattered most

The warning sat inside the auth-router mount, which is gated on
`login_url` being truthy — and header mode's is `None`. Header mode is
one of the two cases the predicate exists to explain, so the operator
least able to work out why `/oauth/*` was missing was the only one who
got no explanation. Hoisted above that gate.

## The chain was tested at both ends and joined nowhere

`max_age=0` and the `reauth_at` marker are what make the gate
enforceable, and neither was asserted: the authorize tests checked
`prompt` only, and the callback tests injected `reauth_at` by hand
instead of letting `/auth/login` write it. Either line could be deleted
with the suite green. Both ends are now pinned, and the state cookie is
decoded to assert the marker is present with `reauth=1` and absent
without it.

## Verified

- 290 tests pass across the auth suite, integration and e2e
- Every fix mutation-checked: gating on `iat` fails 1, denylisting
  instead of allowlisting fails 1, dropping `max_age=0` fails 1,
  renaming the `reauth_at` marker fails 1, stamping `auth_time` without
  the IdP's attestation fails 4
- ruff, ruff format, mypy clean

Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
…gaps

Four items raised in earlier review bodies rather than as inline
comments, which is why they were missed while the anchored findings were
being fixed. None is exploitable; all four are places where a test or an
invariant reads as stronger than it is.

## The bounce built a URL with an HTML escaper

`_bounce_to_login` ran the consent URL through `html.escape` before
appending `&reauth=1`. That is an HTML escaper, not a URL one: it leaves
`?`, `=`, `#` and `+` untouched, so a `#` in the code ended the URL and
turned the rest into a fragment — silently truncating the `return_to`
the user is supposed to come back to.

The review that raised this described a different failure: a crafted
`user_code` injecting `&reauth=0` ahead of the real parameter, letting
the first value win. That does not reproduce — `html.escape` turns `&`
into `&amp;`, so the injected parameter arrives as `amp;reauth` and
`reauth=1` is still the only `reauth`. The conclusion holds anyway, and
for a better reason than the one given: the forced-re-auth invariant
should not depend on an escaper's incidental handling of one character.
`quote(..., safe="/")` is the correct tool and makes the question moot.

A legitimate `user_code` draws from an alphabet with none of these
characters, so nothing was reachable in practice.

## `"/login" in location` could not fail

Three bounce assertions matched a substring that `/auth/login` also
satisfies, so they passed under either provider and proved nothing about
`login_url` in either direction. Now anchored: accounts must bounce to
`/login?`, and the OIDC consent test asserts `/auth/login?`.

## An `unquote` hid what the state cookie actually stores

The `return_to` round-trip test decoded the stored value before
comparing, so it passed whether the query survived the bounce or not —
the exact property it exists to check. Asserts the stored form directly
now, with the input percent-encoded as `_bounce_to_login` emits it.

## The encoding had no test at all

Confirmed by mutation: reverting to `html.escape` left the suite green.
Added a test driving a `user_code` containing the characters
`html.escape` ignores, asserting the consent URL round-trips intact and
that `reauth=1` is the only `reauth` present. Reverting now fails it.

## Documentation

Mounting the grant under OIDC changes neither first-party client: the
CLI and Slack both still probe the mode and still take the cli-ticket
flow there. Noted, along with what the OIDC device-grant routes are
actually for — external clients that need a scoped, revocable,
refreshable credential rather than the server's own session JWT.

## Verified

- 291 tests pass across the auth suite, integration and e2e
- The encoding fix is mutation-checked (reverting it fails 1)
- ruff, ruff format, mypy clean

Signed-off-by: Andrew Peltekci <andrew@peltekci.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@appletechie Thanks for the PR! It doesn't reference an issue yet.

We require an issue for every PR, so the work can be prioritized before it's reviewed. Add one to the description:

  • Closes #123 if this PR finishes the issue. That links it, gives your PR the issue's priority, and closes the issue when this merges. You can also link it from the Development section of the sidebar.
  • Part of #123 if this is one step towards it. Related to, Towards, and Refs work the same way, and leave the issue open.

No issue exists for this yet? Open one first, then reference it. That's how we track what's worth doing, and it's usually quicker than it sounds. Note a reference has to point at an issue: naming another PR doesn't count.

The only exceptions are changes with no user-visible behaviour: pure Refactor / chore, Docs, or Test / CI work. If that's genuinely what this is, check that box under Type of change. Anything that fixes a bug, adds a feature, or changes the UI needs an issue, even when it also touches docs or tests.

See CONTRIBUTING.md for the full policy.

No action is taken beyond this comment.

@github-actions
github-actions Bot requested a review from dbczumar August 7, 2026 01:27
@github-actions github-actions Bot added the size/XL Pull request size: XL label Aug 7, 2026
@appletechie

Copy link
Copy Markdown
Contributor Author

CI is red on Security Scan, and the seven gate / Security Gate failures are all downstream of it (Security Scan concluded: failure). I reproduced the scan locally — it is a false positive, and I would rather show the work than ask for a waiver on assertion.

The finding

::error file=tests/server/test_oidc_callback.py::exfil shape: secret-named source + network sink in one file
Exfil scan found 1 blocking finding(s) in added lines.

The secret scan itself passed ("no secrets in added lines"). What fired is the co-occurrence heuristic in .github/scripts/security-scan/exfil-scan.py, which blocks when a secret-named source and a network sink both appear in one file's added lines. Running it locally against this diff, the three matching lines are:

kind matched line
network sink httpx. ) -> httpx.Response:
network sink httpx. def _session_claims(res: httpx.Response) -> dict[str, object]:
secret source TEST_SECRET return jwt.decode(token, _TEST_SECRET, algorithms=["HS256"])

Both "sinks" are type annotations, not calls. The "secret" is the test HS256 key a helper uses to decode a JWT inside an assertion. Nothing is sent anywhere; there is no network call in the file at all.

Why I did not just rename it

_TEST_SECRET is this repo's established convention for exactly this — it is the name used in tests/server/test_oidc.py (22 occurrences), tests/server/integration/test_oidc_auth_e2e.py, and tests/server/integration/test_oidc_default_policies.py. Renaming it in one new file to dodge a regex would make this file inconsistent with its three siblings and would not make anything safer.

The load-bearing point: tests/server/integration/test_oidc_auth_e2e.py on main today contains both halves of this exact shape_TEST_SECRET plus 18 httpx. lines. It does not trip the scanner only because the scanner reads added lines in a PR diff, never committed files. So the rule is flagging an idiom the repo already ships; it just happens that mine is new.

The ask

Per the scan's own message, skip-security-scan requires Triage, so a fork author cannot self-waive — which is correct design and I am not trying to route around it. If a maintainer agrees with the read above, applying that label re-runs the scan and unblocks the seven gated checks. Everything else on this PR is green.

Happy to do it differently if you would rather harden the heuristic instead — e.g. requiring the network match to look like a call (httpx.(get|post|request|AsyncClient|Client)\() rather than bare httpx., which would drop annotation-only matches without weakening the real exfil case. I am glad to open that as a separate PR against .github/scripts/security-scan/exfil-scan.py if it is wanted; I left it out of this one to keep an auth change and a CI change from riding together.

@appletechie

Copy link
Copy Markdown
Contributor Author

Opened the heuristic fix as its own PR: #4316.

It narrows the exfil scan's httpx sink term from a bare httpx\. to its request-performing attributes, mirroring the requests\. term directly above it — so a -> httpx.Response annotation stops counting as a network call. Detection is unchanged: the module-level verbs and the Client/AsyncClient constructors any client.post(...) is built from all still match.

Verified against this PR specifically: with #4316 applied, the scanner exits 0 on this diff, where main's reports the finding on tests/server/test_oidc_callback.py. It also carries a positive control asserting httpx.post / httpx.stream / httpx.AsyncClient beside a secret-named source still block, and the new false-positive test fails without the change.

So there are two ways to unblock this PR, whichever you prefer:

  1. Merge fix(ci): match httpx by request call, not by bare module, in the exfil scan #4316, then re-run this PR's Security Scan — no waiver needed, and the class of false positive is gone for everyone.
  2. Apply skip-security-scan here if you would rather keep the CI change on its own timeline.

No action needed from me on either path; this PR is otherwise green and MERGEABLE.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@appletechie This PR is a Bug fix, Feature, or UI / frontend change but the Demo section is missing or only contains a placeholder.

These change types require a screenshot or screen recording so reviewers can see the new behaviour without checking out the branch. Please update the Demo section with:

  • A screenshot or screen recording of the change, or
  • A link to a hosted video or GIF showing the new behaviour.

Use N/A only when the change has no user-visible effect whatsoever (e.g. a pure refactor or test-only change). If that's the case, uncheck the relevant type box and check Refactor / chore or Test / CI instead.

@github-actions github-actions Bot added the needs-demo PR needs a demo screenshot or recording label Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-demo PR needs a demo screenshot or recording size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants