skills-ws
Version:
87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
165 lines (134 loc) • 6.99 kB
Markdown
## Contents
- 2. JWT (JSON Web Tokens)
- Structure
- JWT Validation (Node.js) — pin algorithm, issuer, audience
- Refresh Token Rotation — hashed, atomic, reuse-detecting
## 2. JWT (JSON Web Tokens)
### Structure
```
header.payload.signature
Header: { "alg": "RS256", "typ": "JWT", "kid": "key-id-1" }
Payload: { "iss": "https://auth.example.com", "aud": "https://api.example.com",
"sub": "user123", "role": "admin", "iat": 1706000000, "exp": 1706003600 }
Signature: RS256(base64url(header) + "." + base64url(payload), privateKey)
```
### JWT Validation (Node.js) — pin algorithm, issuer, audience
The most common JWT bugs: accepting `alg: none`, accepting whatever `alg` the token claims (HS256/RS256 confusion), or not checking `iss`/`aud`. **Always pin `algorithms` explicitly, and always validate `issuer` and `audience`.** Cache JWKS keys (jwks-rsa caches + rate-limits by default).
```javascript
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true, cacheMaxEntries: 5, cacheMaxAge: 10 * 60 * 1000, // 10 min
rateLimit: true, jwksRequestsPerMinute: 10,
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => callback(err, key?.getPublicKey()));
}
function verifyToken(token) {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'], // pin; never allow 'none' or HS*/RS* mixing
issuer: 'https://auth.example.com', // must match token iss
audience: 'https://api.example.com', // must match token aud (your API/client id)
clockTolerance: 5, // seconds, for minor clock skew
}, (err, decoded) => (err ? reject(err) : resolve(decoded)));
});
}
// Express middleware
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.replace(/^Bearer /, '');
if (!token) return res.status(401).json({ error: 'No token provided' });
try {
req.user = await verifyToken(token);
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}
```
`jsonwebtoken` is the workhorse; for ESM/edge runtimes consider **`jose`** (`jwtVerify` + `createRemoteJWKSet`), which is promise-native and runs on Web Crypto.
### Refresh Token Rotation — hashed, atomic, reuse-detecting
Refresh tokens are long-lived bearer credentials, so treat them like passwords:
1. **Store only a hash** (SHA-256 is fine for a high-entropy random token; you don't need bcrypt/argon2 for 256-bit randomness). The raw token exists only in the client's secure cookie.
2. **Look up by hash**, never by raw value.
3. **Rotate atomically** in a DB transaction with a compare-and-set so two concurrent refreshes can't both succeed.
4. **Detect reuse**: a refresh token is single-use. If a *used/rotated* token is presented again, treat it as theft and **revoke the whole token family**.
```javascript
import crypto from 'node:crypto';
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
const newOpaqueToken = () => crypto.randomBytes(32).toString('base64url'); // 256-bit, unguessable
// Issue at login: create a family, store HASH, return RAW token to client (httpOnly cookie)
async function issueRefreshToken(userId, meta) {
const raw = newOpaqueToken();
const familyId = crypto.randomUUID();
await db.refreshToken.create({ data: {
tokenHash: sha256(raw), userId, familyId,
used: false, revoked: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
userAgent: meta?.userAgent, ip: meta?.ip, // device/session metadata
}});
return raw; // caller sets it as a Secure; HttpOnly; SameSite cookie (path=/auth/refresh)
}
app.post('/auth/refresh', async (req, res) => {
const raw = req.cookies?.refresh_token; // delivered via httpOnly cookie, not body
if (!raw) return res.status(401).json({ error: 'No refresh token' });
const tokenHash = sha256(raw);
try {
const result = await db.$transaction(async (tx) => {
// Lock the row (Postgres) so concurrent refreshes serialize on it.
const [stored] = await tx.$queryRaw`
SELECT * FROM "RefreshToken" WHERE "tokenHash" = ${tokenHash} FOR UPDATE`;
if (!stored) throw { code: 'INVALID' };
// REUSE DETECTION: a used or revoked token presented again => credential theft.
if (stored.used || stored.revoked) {
await tx.refreshToken.updateMany({
where: { familyId: stored.familyId },
data: { revoked: true }, // nuke the entire family
});
throw { code: 'REUSE' };
}
if (stored.expiresAt < new Date()) throw { code: 'EXPIRED' };
// Atomic compare-and-set: only the first concurrent caller flips used:false -> true.
const claim = await tx.refreshToken.updateMany({
where: { id: stored.id, used: false },
data: { used: true },
});
if (claim.count !== 1) throw { code: 'RACE' }; // someone else won; reject this one
// Mint the next token in the SAME family and store its hash.
const nextRaw = newOpaqueToken();
await tx.refreshToken.create({ data: {
tokenHash: sha256(nextRaw), userId: stored.userId, familyId: stored.familyId,
used: false, revoked: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
userAgent: req.get('user-agent'), ip: req.ip,
}});
const user = await tx.user.findUnique({ where: { id: stored.userId } });
const accessToken = jwt.sign(
{ sub: stored.userId, role: user.role },
process.env.JWT_PRIVATE_KEY,
{ algorithm: 'RS256', issuer: 'https://auth.example.com',
audience: 'https://api.example.com', expiresIn: '15m' }
);
return { accessToken, nextRaw };
});
res.cookie('refresh_token', result.nextRaw, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: result.accessToken });
} catch (e) {
if (e?.code === 'REUSE') {
// Optional: log/audit + force user re-auth on all devices.
return res.status(401).json({ error: 'Token reuse detected; family revoked' });
}
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
```
> The `FOR UPDATE` row lock + `updateMany(where: { used: false })` compare-and-set is what makes this safe under concurrency. On databases without `SELECT ... FOR UPDATE`, rely solely on the conditional update's affected-row count (`claim.count === 1`) as the gate — never on a read-then-write without it.
**Token lifetimes:**
- Access token: 15 minutes (short-lived, stateless, RS256)
- Refresh token: 30 days max, rotated on every use, hashed at rest
- ID token: ~1 hour (OIDC user info; validate `nonce` on login)
---