Skip to main content

loom-auth

Authentication and authorization: OAuth2, JWT, RBAC/ABAC, sessions, API keys, MFA. Not for: LLM tokens.

Datos de origen

Repositorio
cosmix/loom
Última actividad en el origen
19 de septiembre de 2026 a las 08:01
Idioma detectado de SKILL.md
inglés
Estrellas
56
Forks
3

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
name
loom-auth
description
Authentication and authorization: OAuth2, JWT, RBAC/ABAC, sessions, API keys, MFA. Not for: LLM tokens.
triggers
["login","logout","signin","signup","register","authentication","authorization","password","credential","jwt token","JWT","OAuth","OAuth2","OIDC","OpenID","SSO","SAML","cookie","refresh token","access token","bearer","authorization header","auth header","401","403","forbidden","unauthorized","RBAC","ABAC","permissions","roles","access control","[Truncated]"]
# Authentication & Authorization Authentication proves *who* you are; authorization decides *what* you may touch. **Authorization is where real bugs cluster** — broken object-level authorization (IDOR/BOLA) is the #1 API risk (OWASP API Security Top 10). Get object-ownership checks right before polishing token plumbing. ## When to Use Auth is security-critical. Subject designs, token/session lifecycle changes, access-control model changes, and any code handling production credentials to adversarial review against the checklist below. Treat a framework's defaults as untrusted until its documented behavior and configured version are verified. Scope boundary: this skill is *mechanisms*. For vuln scanning use `loom-security-scan`; deep audit `loom-security-audit`; architecture threats `loom-threat-model`. ## Authorization: IDOR / BOLA (read this first) Every request that names an object (`/orders/123`, `?user_id=…`, a foreign key in a body) must verify the caller **owns or is granted** that specific object — authentication ("is logged in") is not authorization. The bug is invisible in tests that only use one account. ```typescript // ❌ BOLA: any authenticated user reads any order by guessing the id app.get("/orders/:id", requireAuth, async (req, res) => { const order = await db.orders.findUnique({ where: { id: req.params.id } }); res.json(order); }); // ✅ Scope the query by the authenticated principal (and tenant) app.get("/orders/:id", requireAuth, async (req, res) => { const order = await db.orders.findFirst({ where: { id: req.params.id, userId: req.user.id }, // ownership in the WHERE }); if (!order) return res.status(404).end(); // 404 not 403: don't confirm existence res.json(order); }); ``` Rules: enforce ownership/tenant in the **query filter**, not a post-fetch `if`; check on writes and nested/batch operations too; never trust an `id`/`role`/`tenant` from the client body (mass-assignment → privilege escalation); default-deny. In multi-tenant systems scope every query by `tenant_id` — a missing tenant filter is cross-tenant data leakage. ## OAuth2 Flows | Flow | Use for | Notes | | ---- | ------- | ----- | | Authorization Code + PKCE | Server apps AND SPAs/mobile | PKCE now recommended for **all** clients, not just public ones | | Client Credentials | Service-to-service | No user context; scope tightly | | Device Code | TVs/CLI with no browser | — | | ~~Implicit~~, ~~Password (ROPC)~~ | — | Deprecated by OAuth 2.1; do not use | ```typescript // Authorization Code + PKCE authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("client_id", CLIENT_ID); authUrl.searchParams.set("redirect_uri", REDIRECT_URI); // MUST match a registered exact URI authUrl.searchParams.set("scope", "openid profile email"); authUrl.searchParams.set("state", generateSecureState()); // CSRF: verify on callback authUrl.searchParams.set("code_challenge", challenge); // SHA-256(verifier), base64url authUrl.searchParams.set("code_challenge_method", "S256"); // never "plain" // token exchange (server-side) includes: grant_type=authorization_code, code, code_verifier ``` ⚠ Gotchas: validate `state` on the callback (CSRF); validate `redirect_uri` against an exact allowlist (open-redirect / code interception); `code_challenge_method=S256` only; keep `client_secret` server-side; for OIDC validate the ID token's `nonce`. ## JWT ```typescript function signToken(payload) { return jwt.sign({ ...payload, iat: now() }, PRIVATE_KEY, { algorithm: "RS256", expiresIn: "15m", issuer: "https://api.example.com", audience: "https://app.example.com", }); } function verifyToken(token) { return jwt.verify(token, PUBLIC_KEY, { algorithms: ["RS256"], // PIN the algorithm — never read alg from the token issuer: "https://api.example.com", audience: "https://app.example.com", }); } ``` ### JWT footguns (each is a real CVE class) - **`alg: none`** — a token with no signature. Mitigation: pass an explicit `algorithms` allowlist to verify; never let the library pick from the header. - **RS256 → HS256 key confusion** — attacker flips `alg` to HS256 and signs with your *public* key (which HS256 treats as the HMAC secret). Mitigation: pin `algorithms: ["RS256"]`; never accept the token's declared alg. - **Missing claim validation** — always verify `exp`, `nbf`, `iss`, and `aud`. A valid signature only proves *who issued it*, not *for whom* — without `aud` a token minted for service A is replayable at service B. - **No revocation** — JWTs are valid until `exp`. For logout/ban, keep access tokens short (≤15 min) and gate refresh through a server-side allowlist (below), or maintain a denylist of `jti`. - **Sensitive data in payload** — JWT is signed, not encrypted; base64 is readable. No PII/secrets in claims. - **HS256 in distributed systems** — every verifier needs the shared secret, so any one service can mint tokens. Prefer RS256/ES256 (asymmetric) so verifiers hold only the public key. ### Refresh token rotation (enables revocation) ```typescript async function refreshTokens(refreshToken) { const stored = await db.refreshTokens.findUnique({ where: { token: hashToken(refreshToken) } }); if (!stored || stored.expiresAt < new Date()) throw new UnauthorizedError(); // Reuse detection: if a rotated (deleted) token is presented again, treat as theft → // revoke the whole token family for that user. await db.refreshTokens.delete({ where: { id: stored.id } }); // one-time use const next = generateSecureToken(); await db.refreshTokens.create({ data: { token: hashToken(next), userId: stored.userId, expiresAt: addDays(new Date(), 7) } }); return { accessToken: signToken({ sub: stored.userId }), refreshToken: next }; } ``` Store refresh tokens **hashed** (they are password-equivalent); short access (≤15 min), refresh ≤7 days; deliver refresh tokens in `httpOnly; Secure; SameSite` cookies, never in JS-readable storage. ## RBAC / ABAC RBAC = permissions grouped into roles (simple, coarse). ABAC = policy functions over subject/resource/action/environment attributes (fine-grained, dynamic — needed for ownership, department, time-of-day, tenant). ```typescript // RBAC middleware — expand roles to permissions, honor ":own" resource scope function requirePermission(permission) { return (req, res, next) => { const perms = req.user.roles.flatMap((r) => ROLES[r]); const [resource, action, scope] = permission.split(":"); const ok = perms.includes(permission) || (scope === "own" && perms.includes(`${resource}:${action}`)); if (!ok) return res.status(403).json({ error: "Forbidden" }); next(); // NOTE: ":own" still requires a per-object ownership check (see IDOR/BOLA) }; } ``` ```typescript // ABAC — every policy must pass (default-deny) const policies = [ (ctx) => ctx.resource.owner === ctx.subject.id, (ctx) => ctx.subject.roles.includes("manager") && ctx.resource.department === ctx.subject.department, ]; const allow = (ctx) => policies.every((p) => p(ctx)); ``` ⚠ Middleware-level RBAC checks the *endpoint*; it does not prove the caller owns the *specific object* the route resolves to. Function-level auth without object-level auth is still BOLA. ## Sessions ```typescript async function createSession(userId, req) { const sessionId = crypto.randomUUID(); // ≥128 bits entropy from a CSPRNG await redis.setex(`session:${sessionId}`, SESSION_TTL, JSON.stringify({ userId, createdAt: Date.now(), ip: req.ip, ua: req.headers["user-agent"] })); await redis.sadd(`user-sessions:${userId}`, sessionId); // index for bulk invalidation return sessionId; } ``` Cookie flags: `httpOnly; Secure; SameSite=Lax|Strict; Path=/`. **Regenerate the session ID on privilege change (login, step-up)** to kill session fixation. Absolute (24h) + idle (30m) timeouts. Clear ALL sessions on password change/reset. Store session IDs server-side hashed if they double as bearer tokens. ## API Keys ```typescript function generateApiKey() { const key = `sk_live_${crypto.randomBytes(32).toString("base64url")}`; return { key, prefix: key.slice(0, 16), hash: sha256(key) }; // show key ONCE; store only the hash } ``` Store only the **hash** (fast SHA-256 is fine here — keys are high-entropy, unlike passwords). Prefix (`sk_live_…`) enables identification, log redaction, and secret-scanner detection. Attach scopes + per-key rate limit + expiry. Rotate without downtime by allowing N active keys per principal. ## Password Hashing ```typescript // argon2id — DEFAULT for new systems (memory-hard → resists GPU/ASIC cracking) await argon2.hash(password, { type: argon2.argon2id, memoryCost: 65536, // 64 MiB (OWASP floor 19 MiB; raise until ~0.5–1s/hash on your hardware) timeCost: 3, // iterations parallelism: 4, }); // bcrypt — acceptable legacy; cost ≥ 12 await bcrypt.hash(password, 12); ``` ⚠ Why these and not others: - **argon2id** blends argon2i (side-channel resistance) + argon2d (GPU resistance) — the recommended default. Tune params to your box, not blindly copy. - **bcrypt silently truncates input at 72 bytes** — long passwords / a pepper appended past 72 bytes are ignored. Pre-hash (`base64(sha256(pw))`) before bcrypt if you must exceed 72 bytes. - **Never** MD5/SHA-1/SHA-256/unsalted for passwords — GPUs do billions/sec; fast hashes are the wrong tool. Salt is built into argon2/bcrypt output; don't roll your own. - Verify with the library's `verify`/`compare` (constant-time). Never `hash(input) == stored`. - Peppers (an app-side secret added before hashing) belong in a KMS/HSM, not the DB. ## MFA ```typescript // TOTP: verify with a ±1 step window for clock skew; store the secret encrypted at rest authenticator.verify({ token, secret }); // otplib default window handles skew ``` Prefer **WebAuthn/passkeys** (phishing-resistant, bound to origin) > TOTP > SMS (SIM-swap/interceptable — backup only). Issue 10 single-use backup codes, stored hashed. Require MFA **re-verification** (step-up) for sensitive actions (password/email change, payouts). Rate-limit TOTP attempts (brute-force is 10⁶ codes). ## Timing-Safe Comparison Comparing secrets with `==`/`===`/`strcmp` leaks length and prefix via early-exit timing — an attacker measures response time to recover a token/HMAC byte-by-byte. Use constant-time compare for **any** secret equality: session tokens, API keys, password-reset tokens, HMAC/webhook signatures, MFA codes. ```typescript import { timingSafeEqual } from "crypto"; function safeEqual(a: string, b: string): boolean { const ba = Buffer.from(a), bb = Buffer.from(b); if (ba.length !== bb.length) return false; // length is not secret here; lengths already differ return timingSafeEqual(ba, bb); } ``` Equivalents: Python `hmac.compare_digest`, Go `crypto/subtle.ConstantTimeCompare`, Rust `subtle`/`ring`. For hashed values (argon2/bcrypt) the library's verify already handles this. ## Security Checklist (verify before done) Credentials & tokens: - [ ] Passwords hashed with argon2id (or bcrypt cost ≥12); no fast/unsalted hashes; input ≤72 bytes for bcrypt or pre-hashed - [ ] Refresh tokens, API keys, MFA secrets, reset tokens stored **hashed/encrypted**, never plaintext - [ ] JWT verify pins `algorithms`; validates `exp`, `nbf`, `iss`, `aud`; RS256/ES256 (no `alg:none`, no HS256 in distributed systems) - [ ] No secrets in JWT claims, logs, URLs, or error messages - [ ] All secret/token/HMAC comparisons are constant-time Access control: - [ ] Every object access checks ownership/tenant in the query filter (no IDOR/BOLA); default-deny - [ ] Write/batch/nested operations authorized, not just reads - [ ] Client cannot set `id`/`role`/`tenant`/`isAdmin` via mass assignment - [ ] `:own`-scoped permissions still perform per-object checks Flows & sessions: - [ ] OAuth: `state` verified, `redirect_uri` allowlisted (exact), PKCE `S256` - [ ] Session ID regenerated on login/step-up; `httpOnly; Secure; SameSite` cookies; absolute + idle timeouts; all sessions cleared on password change - [ ] Rate limiting + lockout on auth endpoints; identical error for bad-user vs bad-password (no user enumeration); timing between the two paths comparable - [ ] MFA re-verified for sensitive actions; backup codes hashed & single-use - [ ] HTTPS/HSTS enforced on all auth endpoints; auth events logged for audit (without secrets) ## Complete Login Flow (reference) ```typescript async function login(email, password, mfaCode) { const user = await db.users.findUnique({ where: { email } }); // Run a dummy verify even when user is null so timing doesn't reveal account existence. const ok = await verifyPasswordArgon2(user?.passwordHash ?? DUMMY_HASH, password); if (!user || !ok) { if (user) await incrementFailedAttempts(user.id); throw new UnauthorizedError("Invalid credentials"); // same message either branch } if (user.lockedUntil && user.lockedUntil > new Date()) throw new UnauthorizedError("Account locked"); if (user.mfaEnabled) { if (!mfaCode) return { requiresMfa: true, mfaToken: generateMfaToken(user.id) }; if (!(await verifyMFA(user.id, mfaCode))) throw new UnauthorizedError("Invalid MFA code"); } await db.users.update({ where: { id: user.id }, data: { failedAttempts: 0, lockedUntil: null } }); return { accessToken: signToken({ sub: user.id, roles: user.roles }), refreshToken: await createRefreshToken(user.id) }; } ```
Ver en GitHub