# Developer Guide — Nova Auth (`nova auth`)

> **REQ-346** — developer guide for `nova auth login`. Covers signup,
> signin, login, mode resolution, TTY vs piped stdout behavior, and the
> JWS-from-PAT KDF (REQ-332, C-5.2).
>
> Audience: developers using the Nova CLI to authenticate and run
> `nova apply`. For operator-side identity stack deployment, see
> `docs/operator-guide-idp.md`.

## 1. Quickstart (5 steps)

```sh
# 1. Sign up (one-time per user).
nova auth signup --email alice@example.com --owner team-a

# 2. Sign in (returns a session — valid 24h).
nova auth signin --email alice@example.com

# 3. Issue a PAT and log in (session → OIDC token, stored locally).
nova auth login --pat <PAT>

# 4. Initialize a project (one-time per repo).
nova init

# 5. Apply locally + sign a local-review attestation.
nova apply --local --sign-local-review --contract .nova/contract.yml --pat <PAT>
```

After step 3, `~/.nova/credentials.json` holds your active OIDC token
(see §4). After step 5, the attestation is a JWS verifiable with the
PAT-derived key (see §7).

## 2. `nova auth signup`

Creates a user in the `nova-users` DynamoDB table. The password is
hashed with **Argon2id** (OWASP-minimum parameters: `time_cost=3,
memory_cost=65536 KiB, parallelism=1`) — the raw password is **never**
stored, logged, or put in any env var (INV-16).

```sh
nova auth signup --email alice@example.com --password '...' --owner team-a
```

What happens server-side (the `nova-idp-auth` Lambda):
1. Validates the payload (`email`, `password`, `owner`, `roles`).
2. Checks for a duplicate email → `409` if already registered.
3. `hash_password(password)` → Argon2id hash string.
4. `PutItem` into `nova-users` (`user_id`, `email`, `password_hash`,
   `owner`, `roles`, `created_at`).
5. Emits `auth.sign_up` audit event (carries `user_id` + `email`,
   never the password).

If the Argon2 C extension is unavailable, the Lambda returns **503**
(fail-closed — no weak hash, no pure-Python fallback; D-228).

## 3. `nova auth signin`

Verifies the password and returns a session token.

```sh
nova auth signin --email alice@example.com --password '...'
```

The Lambda:
1. Looks up the user by email (GSI `email-index` on `nova-users`).
2. `verify_password(password, stored_hash)` — Argon2id verify.
3. On mismatch or unknown email → `401 invalid_credentials` (the same
   message for both, so an attacker can't enumerate emails by timing).
4. On success: `create_session(user_id)` writes a row to `nova-sessions`
   (TTL 24h) and returns `session_id`.

## 4. `nova auth login`

Exchanges a PAT (or session) for a Nova OIDC token and stores it
locally.

```sh
nova auth login --pat <PAT>
# or
nova auth login --session <session_token>
```

The flow:
1. The CLI calls the `nova-idp-token-vend` Lambda with the PAT.
2. The Lambda decodes the PAT's `jti`, does a **strongly-consistent**
   `GetItem` on `nova-pats` (D-229 — revocation is reflected on the
   next vend, within 60s P95).
3. Evaluates the ABAC policy (`platform/abac/token-vend.policy`) —
   fail-closed (C-6.1). If the policy engine is unavailable or the
   policy denies, the vend returns `403`.
4. Signs the OIDC token via KMS (`alias/nova-oidc-signing`,
   `ECC_NIST_P256`, `ECDSA_SHA_256`) and returns it.

### The credentials file (`~/.nova/credentials.json`)

**C-7.3 (grill):** the file stores the OIDC token + PAT metadata
(`jti`, `exp`, `type`) **ONLY — NOT the raw PAT.** The raw PAT is
entered once at `nova auth login` and never persisted. This reduces the
filesystem-compromise blast radius: an attacker who reads
`credentials.json` gets a short-lived OIDC token (default 15 min), not
the long-lived PAT.

The file is `0600` (owner read/write only). Shape:

```json
{
  "active_credential_jti": "<jti>",
  "credentials": [
    {
      "jti": "<jti>",
      "type": "nova_oidc_token",
      "exp": 1787200000,
      "token": "<oidc jwt>",
      "stored_at": 1787199000
    }
  ]
}
```

"Most recent wins": `active_credential_jti` points at the
most-recently-stored credential. A subsequent `nova auth login`
replaces the entry with the same `jti` (or adds a new one).

## 5. `nova auth status`

Shows the active credential, the resolved mode, and the
`selection_reason`.

```sh
nova auth status
```

Output (JSON):
```json
{
  "mode": "interactive",
  "selection_reason": "credential:developer_pat",
  "type": "nova_oidc_token",
  "jti": "...",
  "exp": 1787200000
}
```

If no credential is stored: `{"status": "no active credential"}`.

## 6. `nova auth revoke --pat <jti>`

Revokes a PAT by `jti`. Marks the `nova-pats` row `status=revoked`
(the row is **retained** for audit, not deleted). The next
`nova auth login` with that PAT returns `403 pat_revoked` within 60s
P95 (D-229 strong read).

```sh
nova auth revoke --pat <jti>
```

For emergency DDB-level revocation (when the CLI is unavailable), see
`docs/operator-guide-idp.md` §9.

## 7. Mode resolution (D-226)

The CLI resolves a client mode (`interactive` or `agent`) on every
invocation. The mode drives audit observability (INV-12) and some
behavioral defaults. The priority is **strict** — no silent fallbacks
(INV-13):

1. **`--mode` flag** (always wins): `nova apply --mode=agent`.
2. **`NOVA_CLIENT_MODE` env var**: `export NOVA_CLIENT_MODE=agent`.
   Invalid values (anything other than `agent` / `interactive`) are
   **warned and ignored** (fall through to the next level — not a
   silent fallback, because a warning is emitted).
3. **Credential type** (from `~/.nova/credentials.json`): if the active
   credential is `developer_pat` or `nova_oidc_token`, the mode is
   `interactive` if a TTY is attached, `agent` otherwise (INV-14).
4. **TTY heuristic** (`sys.stdin.isatty()`): `interactive` if stdin is
   a TTY, `agent` otherwise.

Every resolution returns a non-empty `selection_reason` (`flag`, `env`,
`credential:<type>`, or `tty`) so the audit event is self-explanatory.

### TTY vs piped stdout — the Edge 3 case

The TTY check is **`sys.stdin.isatty()`**, not `sys.stdout.isatty()`.
This matters when stdout is piped but stdin is still a terminal:

```sh
nova apply | tee log.txt
```

Here `stdout` is a pipe (to `tee`), but `stdin` is still the terminal.
So `sys.stdin.isatty()` returns `True` → **interactive mode**. This is
the common "I want to see the output AND save it" pattern, and it
correctly resolves to interactive because the human is driving.

The inverse — `echo '...' | nova apply` — has `stdin` piped, so
`sys.stdin.isatty()` is `False` → **agent mode** (no human at the
keyboard; the pipe is the driver).

### `developer_pat` + TTY → interactive; + no TTY → agent

A developer PAT (`type: developer_pat`) is a human credential. When a
TTY is attached, the CLI runs in `interactive` mode (prompts, human
confirmation). When no TTY is attached (piped stdin, CI, a scheduled
job), the same PAT runs in `agent` mode (no prompts, non-interactive).
This is INV-14: the credential type encodes the role, and the TTY
encodes the context.

A service-account PAT behaves the same way by type, but the max TTL is
much shorter (≤ 1h vs ≤ 24h for developer PATs — C-6.2) and CI systems
typically set `NOVA_CLIENT_MODE=agent` explicitly so the resolution is
deterministic regardless of the TTY state.

## 8. JWS-from-PAT key derivation (REQ-332, C-5.2)

`nova apply --local --sign-local-review` produces a JWS attestation — a
symmetric (HMAC-SHA256) signature over the attestation payload, keyed
by a key derived from the PAT.

### Why symmetric?

The grill (C-5.2) found that the original REQ-332 acceptance criterion
("public key derivable from the PAT") is unimplementable as an
asymmetric scheme — a PAT is a JWT, not a keypair. The fix: the PAT is
the **shared secret**. Both the signing key and the verification key
are derived from the PAT via the same KDF. The JWS uses `HS256`
(HMAC-SHA256), not `ES256`.

### The KDF

```
key = HKDF-SHA256(
    input_key_material = PAT.encode('utf-8'),
    salt               = b'nova-local-attestation',
    info               = b'jws-signing-key',
    length             = 32,
)
```

(RFC 5869 / NIST SP 800-56C.) The `salt` and `info` are fixed
constants — they bind the derived key to the "nova-local-attestation /
jws-signing-key" purpose (key separation, INV-16). The same PAT always
yields the same key (deterministic); the key is never cached or
persisted (INV-15 — recomputed on each sign/verify call).

### Signing (`nova apply --local --sign-local-review`)

```sh
nova apply --local --sign-local-review --pat <PAT> --contract .nova/contract.yml
```

1. `core.jws_attestation.sign_attestation(payload, pat)`:
   - `derive_signing_key(pat)` → 32-byte key.
   - `header = {"alg":"HS256","typ":"JWT"}`.
   - `signing_input = b64url(header) + "." + b64url(payload)`.
   - `signature = HMAC-SHA256(key, signing_input)`.
   - Returns `b64url(header).b64url(payload).b64url(signature)` (the
     compact JWS serialization).
2. The JWS is appended to the apply output.

### Verifying

Anyone holding the PAT can derive the same key and verify:

```python
from core.jws_attestation import verify_attestation
payload = verify_attestation(jws_string, pat)
# raises JWSValidationError on tampering or wrong PAT
```

`verify_attestation` recomputes the HMAC and compares in constant time
(`hmac.compare_digest`). Without the PAT, the HMAC cannot be forged —
this is the integrity guarantee for local-review attestations.

### What this is NOT

- **Not a non-repudiation scheme.** Anyone with the PAT can sign, so
  the signature proves "someone with the PAT signed this payload" —
  not a specific individual. Non-repudiation is the job of the audit
  trail (INV-12), not the JWS.
- **Not a replacement for the OIDC token.** The OIDC token (from
  `nova auth login`) is the credential for remote operations; the JWS
  is for local-review attestation integrity only.

## 9. Service-account PATs (CI usage)

A CI system (GitHub Actions, or an internal forge runner) uses a service-account
PAT to run `nova apply` non-interactively.

```sh
# In CI:
export NOVA_PAT=<service-account-pat>
export NOVA_CLIENT_MODE=agent
nova auth login --pat "$NOVA_PAT"
nova apply --contract contracts/microservice.yml
```

- `NOVA_CLIENT_MODE=agent` makes mode resolution deterministic (level 2
  beats level 3/4), regardless of whether the CI runner attaches a TTY.
- No TTY → `agent` mode anyway, but the env var is belt-and-suspenders.
- **Max TTL: ≤ 1h for service-account PATs** (C-6.2). The
  `issue_pat(subject_type="service-account", ttl_seconds=3600)` call
  clamps any higher request to 3600s. Rotate the PAT before it expires
  (CI should mint a fresh one per run or daily).

### TTL summary (C-6.2)

| Subject type | Max TTL | Typical use |
|--------------|---------|-------------|
| `developer` | ≤ 24h (86400s) | local dev, interactive |
| `service-account` | ≤ 1h (3600s) | CI, automated pipelines |

The TTL is enforced in `core.pat_lifecycle.issue_pat` — a request for
more than the max is silently clamped (with an audit event recording
the requested vs actual TTL).

---

## Appendix — command reference

| Command | What it does |
|---------|--------------|
| `nova auth signup` | create a user (Argon2id hash) |
| `nova auth signin` | verify password → session token |
| `nova auth login --pat <PAT>` | PAT → OIDC token, store in `~/.nova/credentials.json` (0600) |
| `nova auth status` | active credential + mode + selection_reason |
| `nova auth revoke --pat <jti>` | mark a PAT revoked (D-229 SLO ≤ 60s P95) |
| `nova apply --local --sign-local-review --pat <PAT>` | local apply + JWS attestation (HS256, PAT-derived key) |

| File | Purpose |
|------|---------|
| `~/.nova/credentials.json` | OIDC token + PAT metadata (NOT raw PAT); 0600 |
| `~/.nova/contract.yml` | project contract (scaffolded by `nova init`) |
| `~/.nova/contract.yml.attestations/` | local attestation outputs |