Skip to content

feat(cli): campus auth status (#45) - #49

Merged
ngjunsiang merged 1 commit into
mainfrom
feat/45-auth-status
Oct 9, 2026
Merged

ngjunsiang merged 1 commit into
mainfrom
feat/45-auth-status

Conversation

@ngjunsiang

Copy link
Copy Markdown
Contributor

Summary

campus auth status now reports the stored token's identity — OAuth client, endpoints, principal, scopes, expiry — locally, and (unless --offline) adds a lightweight authenticated validity check. --json emits the same fields machine-readably for scripts and agents (#42).

Acceptance checklist (#45)

  • campus auth status prints client, server, principal, and scopes from the stored credential
  • --json machine-readable output
  • --offline skips any network validation
  • README documents the command in the "Token scopes" workflow (check stored scopes before re-login)

What the store had vs what this adds

Prominent callout: the issue's premise that "#38 stores scopes" turned out not to hold. The #38 short-circuit only bypasses re-login when --scope is absent; it never reads scopes back, and the credential store held no scopes and no principal (access_token, refresh_token, token_auth_url, login_session_id, token_expires_at only). The data does exist at login time — the device-grant token response carries both scope (space-joined) and user_id (verified in campus routes/oauth.py).

Smallest backward-compatible addition, so the contract is met end-to-end:

  • campus_cli/credentials.py: get/set_token_scopes() (space-joined, RFC 6749 scope syntax) and get/set_token_user_id(); both keys are cleared by delete_token().
  • campus_cli/auth/login.py: after token storage, records token_data["scope"] and token_data["user_id"]. Purely additive — no flow, error, or success-path behavior changes; credentials stored before this simply lack the keys and status reports principal/scopes as unknown (not recorded; … until next login). campus auth refresh preserves the recorded scopes (the refresh grant reissues exactly the granted scopes, RFC 6749 §6), so no change there.

Design notes

Default probe — what and why. No whoami/tokeninfo route exists in the SDK's resource surface. I surveyed two candidates:

  1. POST /auth/v1/root/ {"token": …} — introspects the token and even returns client+user, but the route module is explicitly documented "to be used only by Campus backend services"; building a CLI feature on it is fragile.
  2. Chosen: GET <auth>/users/ with the bearer, classified by status code alone — 200/403 FORBIDDEN = token authenticated (valid, just lacks users:read, which is operator/admin-gated server-side), 401 UNAUTHORIZED = rejected. This is the error-envelope distinction the issue suggested.

The probe targets the endpoint that minted the token (token_auth_url), falling back to the current target for pre-binding credentials — mirroring logout's revoke-at-issuing-endpoint rule.

Raw requests instead of the campus_python client: the SDK's CampusRequest wraps requests.RequestException as errors.ServerError, conflating "could not reach server" with a real HTTP 500 — the three-state machine needs them apart. It also matches login.py's device-flow HTTP style.

No-network state machine (documented in --help and the module docstring):

State Meaning Exit
not logged in friendly message (human) / authenticated: false (JSON) 1, both modes
valid probe got 200/403 0
invalid probe got 401 — remedy (campus auth login) printed 1
unreachable network error — warning, identity still printed (chosen non-fatal, per the issue's "pick one and document") 0
unknown inconclusive (e.g. 5xx), note carried 0
not_checked --offline 0

--json keys: the pre-existing authenticated/expires_at/is_expired/can_refresh/auth_url/token_auth_url/endpoint_match are kept verbatim (backward compatible); new keys client_id, user_id, scopes (null = not recorded, [] = genuinely none), expires_in_seconds, token_validity, probe_url, probe_note. JSON is written via typer.echo (repo convention: Rich wrapping must not corrupt machine output).

Test / verify results

  • poetry run pytest -q → 199 passed (baseline 175 + 24 new); no live-network tests — the probe is always mocked or pointed at a closed port
    • new tests/unit/test_status.py: probe status-code mapping (200/403/401/5xx/network), assembly (present/absent credential, scopes recorded vs not-recorded vs empty, expiry math), JSON vs human output, offline-vs-probe paths, exit codes, probe-target selection
    • tests/unit/test_login.py: login persists scope/user_id; omits both when the response lacks them (older servers)
    • tests/integration/test_cli.py: updated for the feat(cli): campus auth status — show stored token identity and scopes #45 contract — not-logged-in is now exit 1 in both modes (previously 0), and status tests mock the probe/store fields
  • poetry run ruff check campus_cli/ tests/ → clean
  • Smoke (real CLI entry point, isolated store — the developer keyring was never touched):
    • campus auth status --help — renders the exit-code/state documentation
    • empty store: human and --json print the not-logged-in message cleanly, exit 1 in all three modes (default/--json/--offline)
    • populated store vs closed port: full identity block + ⚠ Token check … could not reach server … — validity unknown, exit 0; --offline prints Token check: skipped (--offline)

Closes #45

…#45)

Tokens carry exactly the scopes requested at login (no accumulation),
but the credential store never recorded them: the only way to learn
what a stored token could do was to hit a 403 and re-login. The store
now records the granted scopes and authorizing principal at login
(additive keys, written from the device grant response), and
`campus auth status` reports them alongside the client id, endpoints
and expiry — with a --json mode for scripts and agents.

The default mode adds a lightweight authenticated validity check:
GET <auth>/users/ with the bearer, classified by status code only
(FORBIDDEN = valid-but-unprivileged, UNAUTHORIZED = rejected). The
probe targets the endpoint that minted the token, mirroring logout's
revoke-at-issuing-endpoint rule. --offline skips it. Network errors
are a third, non-fatal state (warning, exit 0); a rejected token
exits 1, as does not-logged-in.
@ngjunsiang
ngjunsiang merged commit a4d0d1b into main Oct 9, 2026
4 checks passed
@ngjunsiang
ngjunsiang deleted the feat/45-auth-status branch October 20, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(cli): campus auth status — show stored token identity and scopes

1 participant