Merge branch 'feat/v0.7-auth-iam' — 4 blocks + /auth-setup

This commit is contained in:
Parfii-bot 2026-04-21 21:11:39 +08:00
commit 0081bc8f46
6 changed files with 543 additions and 0 deletions

102
skills/auth-setup/SKILL.md Normal file
View file

@ -0,0 +1,102 @@
---
name: auth-setup
description: Hub-and-spoke pipeline that produces a production-grade auth/IAM plan for a new or existing app — user flows, identity providers, session strategy, authorization model, and threat mitigations — via pure-click decisions across five phases. Emits a scaffolded env-var list, library picks, and a per-threat checklist; never writes secrets.
argument-hint: <one-line app description, e.g. "B2C SaaS, Next.js + Postgres, needs Google + passkeys">
---
# Auth-Setup — Identity, Session & Authorization Pipeline (index)
You are converting "I need auth for app X" into a concrete, reviewable plan:
which identity methods to ship, which providers to register, which session
strategy to pick, which authorization model to enforce, and which threats to
mitigate up front. Every decision is a click; the only typed input is the
one-line app description in Phase 1.
This skill does NOT write production code. It emits a plan, the env-var
scaffold, the library picks, and a per-threat checklist. Code scaffolding
is a separate task owned by `new-agent` or the project's own
code-implementer.
The skill reads the four companion blocks heavily — every phase references
at least one of them:
- `_blocks/auth-oauth2-oidc.md` — OAuth2 / OIDC flows, PKCE, providers.
- `_blocks/auth-passkeys.md` — WebAuthn registration + assertion, RP ID.
- `_blocks/auth-sessions.md` — server sessions vs JWT tradeoff, cookies.
- `_blocks/auth-authorization.md` — RBAC / ABAC / ReBAC, policy engines.
---
## Pipeline overview (5 phases, ≥6 AskUserQuestion calls)
| Phase | File | Purpose | AskUserQuestion |
|---|---|---|---|
| 1 | [phase-1-intake.md](phase-1-intake.md) | App flows, stack, storage, MFA | 4× |
| 2 | [phase-2-identity-provider.md](phase-2-identity-provider.md) | Pick + configure IdPs; env scaffold | 1× |
| 3 | [phase-3-session-strategy.md](phase-3-session-strategy.md) | Server-session vs JWT; cookie flags | 1× |
| 4 | [phase-4-authorization.md](phase-4-authorization.md) | RBAC / ABAC / ReBAC; permission matrix | 1× |
| 5 | [phase-5-threats.md](phase-5-threats.md) | CSRF / XSS / timing / enumeration | 1× |
Minimum AskUserQuestion count across a full session: **8** (4 in Phase 1 +
1 each in Phases 25). Exceeds the ≥6 hub-and-spoke contract.
---
## Variables the pipeline produces
| Name | Set in | Meaning |
|---|---|---|
| `FLOWS` | Phase 1 | subset of {email+password, magic-link, OAuth, passkey, SSO} |
| `STACK` | Phase 1 | Next / Remix / SvelteKit / Astro / Rust axum / FastAPI / other |
| `STORAGE` | Phase 1 | Postgres / SQLite / MySQL / Supabase / managed-auth |
| `MFA` | Phase 1 | none / TOTP / passkey / WebAuthn-as-2FA |
| `PROVIDERS` | Phase 2 | list of OAuth/OIDC providers with env-var names |
| `SESSION` | Phase 3 | server-session OR JWT + cookie config |
| `AUTHZ` | Phase 4 | RBAC / ABAC / ReBAC + policy engine (or none) |
| `THREATS` | Phase 5 | mitigation checklist, per threat class |
---
## Final report (emit after Phase 5)
```
=== AUTH-SETUP REPORT ===
App: <first 80 chars of intake>...
Stack: <STACK> + <STORAGE>
Flows: <FLOWS list> MFA: <MFA>
Providers: <PROVIDERS with env var names>
Session: <SESSION summary line>
Authz: <AUTHZ model + engine if any>
Threats: <N mitigations selected>
Libraries: <pick per language, one line>
Env vars: <count> new entries for secrets/<file>.env
Next: run `compose-solution` or hand off to project code-implementer
```
---
## Rules (apply throughout)
- **Pure-click contract.** Only the Phase 1 intake paragraph is typed.
Every other decision is `AskUserQuestion`.
- **RULE 0.8 Secrets SSoT.** The skill emits env VARIABLE NAMES only
(`GOOGLE_CLIENT_ID`, `APPLE_TEAM_ID`, ...). It NEVER echoes a token
value, never writes to `secrets/*.env`, never suggests hard-coding.
Storage path is `<repo>/secrets/auth.env` per `domain-has-secrets.md`.
- **NO DOWNGRADE.** If the chosen combination is unsafe (e.g.
passkey-only without a recovery flow) the skill returns 23 constructive
alternatives, never "not supported".
- **Fail-closed default.** Every authz / session / threat decision
defaults to the safer option when the user is unsure.
- **Surgical scope.** Reads the four auth blocks; writes nothing outside
its own phase files. Production scaffolding is delegated.
---
## References
- `_blocks/auth-oauth2-oidc.md`, `_blocks/auth-passkeys.md`,
`_blocks/auth-sessions.md`, `_blocks/auth-authorization.md`.
- `_blocks/domain-has-secrets.md` — storage path + loading convention.
- `_blocks/rule-pre-dev-gate.md` — analogue check before inventing.
- Evidence grade [E2] — pipeline mirrors OWASP ASVS v4.0.3 chapters 24.

View file

@ -0,0 +1,115 @@
# Phase 1 — Intake (flows, stack, storage, MFA)
One free-text paragraph, then four click batches. This is the only phase
that accepts typed input.
## 1a — Ask for the app description
Emit a regular message (NOT AskUserQuestion):
> Describe the app in one paragraph: what is it, who signs in, and any
> constraint I should know (regulated industry, existing user table,
> multi-tenant, mobile-first, etc.). Reply in one message.
Store the reply verbatim as `INTAKE`.
## 1b — User-flow click (AskUserQuestion, multi-select)
```json
{
"questions": [
{
"question": "Which login flows should the app support?",
"header": "Flows",
"multiSelect": true,
"options": [
{"label": "Email + password", "description": "Classic — requires password hash (argon2id), breach-check (HIBP), reset email flow"},
{"label": "Magic link", "description": "Email-only, one-time link; removes password surface, depends on email deliverability"},
{"label": "OAuth / social", "description": "Google / GitHub / Apple / Microsoft — see _blocks/auth-oauth2-oidc.md"},
{"label": "Passkey (WebAuthn)", "description": "Phishing-resistant, passwordless — see _blocks/auth-passkeys.md"},
{"label": "Enterprise SSO", "description": "SAML / OIDC to Okta / Entra ID / Keycloak — B2B multi-tenant"}
]
}
]
}
```
Store the multi-selection as `FLOWS`. Empty selection → re-ask.
## 1c — Stack click (AskUserQuestion, single-select)
```json
{
"questions": [
{
"question": "Primary framework / runtime?",
"header": "Stack",
"multiSelect": false,
"options": [
{"label": "Next.js (App Router)", "description": "Server Components + Server Actions; Better-Auth or Auth.js"},
{"label": "Remix / React Router", "description": "Loader/action model; remix-auth + Better-Auth"},
{"label": "SvelteKit", "description": "Hooks + form actions; Lucia replacement = Better-Auth or custom"},
{"label": "Astro", "description": "Islands + middleware; Better-Auth or external auth"},
{"label": "Rust (axum / actix)", "description": "axum-login + tower-sessions + webauthn-rs + openidconnect-rs"},
{"label": "Python (FastAPI)", "description": "authlib + starlette SessionMiddleware + py_webauthn"},
{"label": "Other / specify", "description": "Add a free-text note; skill will pick libraries in Phase 2"}
]
}
]
}
```
Store as `STACK`.
## 1d — Storage click (AskUserQuestion, single-select)
```json
{
"questions": [
{
"question": "Where will user + session rows live?",
"header": "Storage",
"multiSelect": false,
"options": [
{"label": "Postgres", "description": "Recommended default — ACID, row-level security, good for multi-tenant"},
{"label": "SQLite", "description": "Single-node, edge-friendly; fine up to ~100k users"},
{"label": "MySQL / MariaDB", "description": "Existing stack compatibility"},
{"label": "Managed (Supabase / Clerk / Auth0 / WorkOS)", "description": "Off-load auth entirely — skill emits integration plan, not self-hosted tables"},
{"label": "Redis (sessions only)", "description": "Paired with a primary DB for users"}
]
}
]
}
```
Store as `STORAGE`.
## 1e — MFA click (AskUserQuestion, single-select)
```json
{
"questions": [
{
"question": "Multi-factor requirement?",
"header": "MFA",
"multiSelect": false,
"options": [
{"label": "None", "description": "Consumer app, low risk surface"},
{"label": "TOTP (authenticator app)", "description": "RFC 6238; pair with 10 one-time recovery codes"},
{"label": "Passkey as 2FA", "description": "WebAuthn with user-verification=required, after password or magic link"},
{"label": "Required for admins only", "description": "RBAC rule: role=admin → MFA gate before privileged actions"}
]
}
]
}
```
Store as `MFA`.
## Verify-criterion
- `INTAKE` non-empty.
- `FLOWS` has ≥1 entry.
- `STACK`, `STORAGE`, `MFA` each exactly one label.
- If `FLOWS = {Passkey}` ONLY and `MFA = None` → warn "passkey-only requires a
recovery path" and return to 1b (NO DOWNGRADE: present recovery options).

View file

@ -0,0 +1,79 @@
# Phase 2 — Identity provider selection + env-var scaffold
Only runs if `FLOWS` contains `OAuth / social` or `Enterprise SSO`. If
neither is selected, skip to Phase 3 (passkey-only or magic-link apps
don't need external IdPs).
## 2a — Provider click (AskUserQuestion, multi-select)
Reference: `_blocks/auth-oauth2-oidc.md`.
```json
{
"questions": [
{
"question": "Which identity providers to register?",
"header": "Providers",
"multiSelect": true,
"options": [
{"label": "Google", "description": "OIDC; discovery at accounts.google.com/.well-known/openid-configuration"},
{"label": "GitHub", "description": "OAuth2 only (no OIDC discovery); hard-code endpoints"},
{"label": "Apple", "description": "OIDC; name/email returned ONLY on first consent — persist immediately"},
{"label": "Microsoft", "description": "OIDC multi-tenant via login.microsoftonline.com/common/v2.0"},
{"label": "Enterprise OIDC (Okta / Auth0 / Keycloak / Entra)", "description": "B2B SSO; per-tenant discovery URL"},
{"label": "SAML 2.0 (legacy enterprise)", "description": "Use a library like samlify (TS) or python3-saml; NOT OAuth"}
]
}
]
}
```
Store as `PROVIDERS`. Empty → skip Phase 2.
## 2b — Emit env-var scaffold (no AskUserQuestion)
For EACH provider in `PROVIDERS`, emit the env-var rows the user must add
to `secrets/auth.env`. NEVER emit values — names only. Example for Google:
```bash
# secrets/auth.env — add these, then `chmod 600` the file
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://<app>/auth/google/callback
GOOGLE_OIDC_DISCOVERY=https://accounts.google.com/.well-known/openid-configuration
```
Per-provider scaffold rules:
- **Google / Microsoft / Apple / Enterprise OIDC:** `*_CLIENT_ID`,
`*_CLIENT_SECRET` (confidential) OR `*_CLIENT_ID` + PKCE only (public
SPA/mobile), `*_REDIRECT_URI`, `*_OIDC_DISCOVERY`.
- **Apple** adds `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY_PATH`
(path to the `.p8` file — stored inside `secrets/`, never inline).
- **GitHub:** `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`,
`GITHUB_REDIRECT_URI`. No discovery URL.
- **SAML:** `SAML_IDP_METADATA_URL`, `SAML_SP_ENTITY_ID`,
`SAML_SP_ACS_URL`, `SAML_SP_PRIVATE_KEY_PATH`,
`SAML_SP_CERT_PATH`.
Emit the snippet as a fenced code block in chat. Remind the user once:
"File `secrets/auth.env` must be `chmod 600` and listed in `.gitignore`
BEFORE the first write. See `_blocks/domain-has-secrets.md`."
## 2c — Library pick (emitted inline, no AskUserQuestion)
Driven by `STACK` from Phase 1:
- **Next.js / Remix / SvelteKit / Astro:** Better-Auth (preferred 20252026)
OR NextAuth/Auth.js (Next-only, mature). Both support PKCE by default.
- **Rust (axum):** `openidconnect-rs` (OIDC) or `oauth2-rs` (OAuth2 bare).
- **Python (FastAPI):** `authlib` (covers both OAuth2 and OIDC).
- **Managed (Clerk / Supabase / WorkOS):** provider SDK only; this phase
just records the SDK name.
## Verify-criterion
- Every provider in `PROVIDERS` has its env-var scaffold printed.
- No literal token value appears anywhere in the emitted text
(RULE 0.8 / `auth-oauth2-oidc.md` enforcement).
- Library pick is one line, matches `STACK`.

View file

@ -0,0 +1,79 @@
# Phase 3 — Session strategy + cookie configuration
Decides how the authenticated principal is carried across requests. Reads
`_blocks/auth-sessions.md` heavily. Default bias: server-side opaque
sessions (revocable, simple) unless the user needs horizontal stateless
scale.
## 3a — Strategy click (AskUserQuestion, single-select)
```json
{
"questions": [
{
"question": "Session carrier?",
"header": "Session",
"multiSelect": false,
"options": [
{"label": "Server-side session + cookie (DEFAULT)",
"description": "Opaque 256-bit id in HttpOnly Secure cookie; row in DB/Redis. Instant revoke. Recommended for >95% of apps."},
{"label": "JWT access + opaque refresh",
"description": "ES256 access ≤15 min in HttpOnly cookie; refresh rotated server-side. Use ONLY when you have stateless edge workers that can't reach the session DB."},
{"label": "JWT access + refresh in native secure storage",
"description": "Mobile app; refresh in Keychain / Keystore. Same rotation rules; cookie flags N/A."},
{"label": "Managed (Clerk / Supabase / Auth0)",
"description": "Provider owns the session primitive; skill records the SDK integration points only."}
]
}
]
}
```
Store as `SESSION`.
## 3b — Emit cookie-flag checklist (inline, no AskUserQuestion)
Apply ONLY when `SESSION` involves a browser cookie. For every cookie the
app sets (session, CSRF, anti-re-use nonce):
```
[ ] HttpOnly — blocks JS read; XSS-resistant
[ ] Secure — HTTPS only; reject on cleartext
[ ] SameSite=Lax — default; use Strict for cross-site-hostile apps
[ ] __Host- prefix — no Domain, Path=/, Secure — session cookie only
[ ] Max-Age tuned — 730 d sliding (consumer) / 24 h hard (regulated)
[ ] Rotation on login, — new session_id issued, old row deleted or revoked_at set
logout-all, passkey/password change, privilege elevation
[ ] Logout deletes BOTH — server row AND cookie (Max-Age=0, same flags)
```
## 3c — Emit JWT-specific checklist (inline) — only if JWT chosen
```
[ ] Algorithm = ES256 — asymmetric; NOT HS256 for cross-service
[ ] access_token ≤15 min — minimises revocation-gap window
[ ] refresh_token OPAQUE — stored server-side, rotated on every use
[ ] refresh-reuse detection — family revocation on stolen refresh
[ ] JWKS rotation + kid — key rollover without service restart
[ ] Claims validated — iss, aud, exp, nbf, iat, sub, nonce (if OIDC)
[ ] NEVER in localStorage — HttpOnly cookie (web) / secure storage (native)
[ ] Logout policy stated — "revoke refresh only; access valid until exp"
and accepted by the product (or escalate to server-session strategy)
```
## 3d — CSRF strategy (inline, driven by SESSION)
- Cookie session + same-origin forms → `SameSite=Lax` is enough; plus a
CSRF token (cookie+header double-submit) for cross-origin POSTs.
- Cookie session + third-party embed (iframes, extensions) → `SameSite=None;
Secure` + mandatory CSRF token, reject missing/mismatched.
- Bearer-token API (no cookie) → no CSRF (no ambient credential); enforce
`Origin` header check as defence-in-depth.
## Verify-criterion
- `SESSION` set to exactly one strategy.
- At least one of the three checklists (3b / 3c / 3d) applies and was
emitted.
- If JWT chosen, 3c is printed in full AND the logout gap was explicitly
acknowledged in the report.

View file

@ -0,0 +1,84 @@
# Phase 4 — Authorization model + permission matrix
Decides who-can-do-what after authentication. Reads
`_blocks/auth-authorization.md`. Fail-closed by default.
## 4a — Model click (AskUserQuestion, single-select)
```json
{
"questions": [
{
"question": "Authorization model?",
"header": "Authz",
"multiSelect": false,
"options": [
{"label": "RBAC (roles → permissions)",
"description": "Static roles (admin / editor / viewer). Simplest, enough for most apps with <5 roles."},
{"label": "RBAC + ownership",
"description": "Roles + per-row owner_id check. The sweet spot for multi-tenant SaaS."},
{"label": "ABAC (policy engine)",
"description": "Attributes + context (time, IP, resource tier). Use Cerbos or OPA. Adopt when rule count >~20."},
{"label": "ReBAC (Google Zanzibar)",
"description": "Graph-shaped sharing (folders/teams/orgs). SpiceDB or OpenFGA. Pick only if your domain is inherently graph-shaped."},
{"label": "None — single-user app",
"description": "No authz layer beyond authentication. Record explicitly."}
]
}
]
}
```
Store as `AUTHZ`.
## 4b — Emit permission matrix skeleton (inline)
For `RBAC` / `RBAC + ownership`, emit a table stub the user must fill in
before coding. Example for a notes app:
```markdown
| Role | notes:read | notes:write | notes:delete | users:manage |
|--------|:---------:|:-----------:|:------------:|:------------:|
| admin | all | all | all | yes |
| editor | owned | owned | owned | no |
| viewer | owned | no | no | no |
```
- Columns = `resource:action` tokens — these become the `Permission` enum
variants in code.
- Cells = `all` / `owned` / `no` / `shared:<relation>` — NEVER free-text.
- Save as `docs/permissions.md` in the target repo; treat it as code
(tested, reviewed, versioned).
## 4c — Enforcement-point rule (inline)
- Middleware, not handlers. Every authenticated request runs an authz
decision BEFORE the handler sees it. Handler receives a typed
`AuthorizedRequest<Action, Resource>` or the request 403s earlier in the
stack.
- Ownership checks enforced in BOTH the middleware AND the data layer
(`WHERE tenant_id=$1 AND owner_id=$2`). Double layer defeats IDOR.
- Fail-closed: unknown action, missing role, policy-engine error → 403.
Log every denial with subject + action + resource + reason.
- Audit log append-only row on every privilege change, role assignment,
and denied action. Required for SOC2 / HIPAA / ISO 27001.
## 4d — Policy-engine pick (inline, driven by AUTHZ)
- `RBAC` / `RBAC + ownership` → in-code match on `Permission` enum; no
engine.
- `ABAC` → Cerbos (YAML rules, stateless decision service) OR OPA/Rego
(general-purpose, steeper curve). Keep `.cerbos.yaml` / `.rego` files
in the repo, unit-tested like code.
- `ReBAC` → SpiceDB (Zanzibar reference) OR OpenFGA (Auth0-backed).
Define the schema, seed relationships, use the client SDK.
- `None` → emit a single line "authz skipped — no multi-user model".
## Verify-criterion
- `AUTHZ` is exactly one choice.
- If RBAC chosen, permission-matrix skeleton with ≥1 row + ≥1 column is
printed.
- Enforcement-point rule (4c) is emitted verbatim — non-negotiable.
- If a policy engine is implied by `AUTHZ`, the pick is named (Cerbos /
OPA / SpiceDB / OpenFGA).

View file

@ -0,0 +1,84 @@
# Phase 5 — Threats & mitigations
Close the pipeline with a per-threat checklist. The user picks which
mitigations to commit to; the skill emits them into the final report so
they get tracked as acceptance criteria.
## 5a — Threat-class click (AskUserQuestion, multi-select, pre-checked)
Pre-select every item by default — opting OUT requires a click, opting IN
is the cheap path (fail-closed bias).
```json
{
"questions": [
{
"question": "Confirm the threat mitigations to enforce (pre-checked; deselect only if you have a compensating control)?",
"header": "Threats",
"multiSelect": true,
"options": [
{"label": "CSRF — SameSite + token",
"description": "SameSite=Lax default; double-submit token for cross-origin POSTs; reject on mismatch"},
{"label": "XSS — HttpOnly + CSP",
"description": "HttpOnly on every auth cookie; strict CSP (no inline script); sanitise every rendered string; NEVER put session or JWT in localStorage"},
{"label": "Session fixation — rotate on login",
"description": "New session_id issued at every privilege change (login, logout-all, password/passkey change, MFA step-up)"},
{"label": "Account enumeration — uniform responses",
"description": "Same timing + wording for 'user not found' and 'wrong password'; signup and reset flows respond identically regardless of address existence"},
{"label": "Timing attacks — constant-time compare",
"description": "Use subtle.timingSafeEqual / crypto.constant_time_compare on password hash, token, session_id lookups"},
{"label": "Password policy — argon2id + HIBP",
"description": "argon2id hashing (memory≥64MB, t≥3); reject passwords found in HaveIBeenPwned k-anonymity API; min length 12, no max"},
{"label": "Brute-force — rate limit + lockout",
"description": "Per-account exponential backoff; per-IP sliding window; CAPTCHA after N failures; unlock via email or time"},
{"label": "Email-link security",
"description": "One-time tokens (random 32B, SHA-256 in DB); ≤15 min TTL; single-use; bound to email address at issue time"},
{"label": "OAuth state + nonce",
"description": "state (CSRF) + nonce (replay) on every authorize request; reject on mismatch; see _blocks/auth-oauth2-oidc.md"},
{"label": "Passkey recovery path",
"description": "Backup codes OR email magic-link OR OAuth fallback; user opts out only after explicit warning"},
{"label": "Logging without leakage",
"description": "Never log raw password, TOTP secret, session_id, or access_token; log userID + action + result only"},
{"label": "Dependency hygiene",
"description": "Auth library at latest patched version; CVE scan in CI; pin via lock file"}
]
}
]
}
```
Store the confirmed subset as `THREATS`. Any item the user deselects must
have a one-line justification recorded in the final report.
## 5b — Emit threat-by-threat implementation hints (inline)
For each item in `THREATS`, print ONE implementation line. Examples:
- `CSRF` → "middleware double-submit: cookie `__Host-csrf` + `X-CSRF-Token`
header; `subtle.timingSafeEqual` on compare."
- `Timing attacks` → "Rust `subtle::ConstantTimeEq`; Node
`crypto.timingSafeEqual`; Python `hmac.compare_digest`."
- `Passkey recovery` → "register 10 single-use codes at passkey creation;
store argon2id hashes; mark consumed on use."
Keep each line short — full guidance lives in the upstream blocks, not in
this phase.
## 5c — Final report assembly
After Phase 5 completes, emit the final report template from SKILL.md with
all variables filled. Add at the bottom:
```
Deselected threats (with justification):
- <threat name>: <one-line justification>
(or "none" if THREATS covers every default)
```
## Verify-criterion
- `THREATS` has ≥8 of the 12 defaults selected; each deselection carries a
justification line.
- Every selected threat has an implementation hint printed (5b).
- Final report (5c) emits the full `=== AUTH-SETUP REPORT ===` block from
SKILL.md.