AgentStack
SKILL unreviewed MIT Self-run

Auth Implementation

skill-san-npm-skills-ws-auth-implementation · by san-npm

Secure authentication & authorization — OAuth 2.1/OIDC with PKCE & state, JWT/JWKS verification, hashed-rotating refresh tokens, sessions/BFF, passkeys/WebAuthn, MFA/TOTP, RBAC/ABAC, password hashing, and CSRF. Use when implementing or reviewing auth, authz, MFA, passkeys, OAuth/OIDC, sessions, tokens, or access control.

No reviews yet
0 installs
8 views
0.0% view→install

Install

$ agentstack add skill-san-npm-skills-ws-auth-implementation

Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.

Security review

⚠ Flagged

1 finding(s); flagged for manual review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures
  • high Reads credentials/environment and may exfiltrate them.

What it can access

  • Network access Used
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets Used
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Auth Implementation? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Authentication & Authorization

Security-critical patterns for AuthN/AuthZ in 2026. Code here is meant to be copied, so it is written to be correct and safe by default: every secret stored hashed, every token rotation atomic, every redirect-based flow CSRF-protected via state/PKCE. Vendor endpoints and library APIs drift — when a value here is dated, the inline note tells you where to re-verify.

Threat-model defaults: assume the browser is hostile (XSS can read anything JS can), assume tokens leak, assume requests are replayed and races happen. Prefer short-lived access tokens + server-held session/refresh state. For SPAs, prefer a BFF (Backend-for-Frontend) holding tokens server-side over putting access tokens in localStorage.


1. OAuth 2.1 / OIDC Flows

OAuth 2.1 (the consolidation of 2.0 + best-practice RFCs) makes PKCE mandatory for all clients, forbids the implicit and password grants, and requires exact redirect-URI matching. Use the Authorization Code flow + PKCE everywhere (yes, even confidential server-side clients).

Discover endpoints (don't hardcode)

Prefer the provider's OIDC discovery document over hardcoded URLs so endpoints and the JWKS URI stay correct:

// Fetch once at boot, cache in memory (respect Cache-Control)
const discovery = await fetch(
  'https://accounts.google.com/.well-known/openid-configuration'
).then(r => r.json());
// => { authorization_endpoint, token_endpoint, jwks_uri, issuer, ... }
// Google (as of Jun 2026): authorization_endpoint = https://accounts.google.com/o/oauth2/v2/auth
//                          token_endpoint         = https://oauth2.googleapis.com/token
// Verify: https://accounts.google.com/.well-known/openid-configuration

Authorization Code + PKCE — full server-side flow with state

This is the canonical flow. state and the PKCE code_verifier are both persisted server-side before redirect and verified on callback — skipping either reopens CSRF / login-injection / code-injection. Below, secrets live on the server and only code_challenge + state ever hit the browser.

import crypto from 'node:crypto';

const b64url = (buf) => buf.toString('base64url'); // Node >=16 supports 'base64url'

function pkcePair() {
  const verifier = b64url(crypto.randomBytes(32));               // 43-128 chars, high entropy
  const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
  return { verifier, challenge };
}

// --- Step 1: begin login (server route) ---
app.get('/auth/login', async (req, res) => {
  const state = b64url(crypto.randomBytes(32));
  const nonce = b64url(crypto.randomBytes(32));   // OIDC: binds id_token to this session
  const { verifier, challenge } = pkcePair();

  // PERSIST state + verifier + nonce server-side, keyed to THIS session, BEFORE redirecting.
  // Short TTL; single use. (Express-session shown; a signed httpOnly cookie also works.)
  req.session.oauth = { state, nonce, verifier, createdAt: Date.now() };

  const url = new URL(discovery.authorization_endpoint);
  url.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID);
  url.searchParams.set('redirect_uri', process.env.OAUTH_REDIRECT_URI); // must EXACTLY match registered URI
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('scope', 'openid email profile');
  url.searchParams.set('code_challenge', challenge);
  url.searchParams.set('code_challenge_method', 'S256');
  url.searchParams.set('state', state);
  url.searchParams.set('nonce', nonce);
  res.redirect(url.toString());
});

// --- Step 2: callback (server route) — VALIDATE everything ---
app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;
  const saved = req.session.oauth;
  delete req.session.oauth; // consume immediately so it can't be replayed

  // (a) provider returned an error?
  if (req.query.error) return res.status(400).send(`OAuth error: ${req.query.error}`);
  // (b) we actually started a flow, and it hasn't expired
  if (!saved || Date.now() - saved.createdAt > 10 * 60 * 1000) {
    return res.status(400).send('No pending OAuth flow / expired');
  }
  // (c) STATE MUST MATCH — constant-time compare to avoid timing oracles
  const ok = typeof state === 'string'
    && state.length === saved.state.length
    && crypto.timingSafeEqual(Buffer.from(state), Buffer.from(saved.state));
  if (!ok) return res.status(403).send('Invalid OAuth state'); // CSRF / login-injection blocked here
  // (d) need a code
  if (typeof code !== 'string' || !code) return res.status(400).send('Missing authorization code');

  // (e) exchange code — token request is form-encoded; include the PKCE verifier we persisted
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: process.env.OAUTH_REDIRECT_URI,
    client_id: process.env.OAUTH_CLIENT_ID,
    code_verifier: saved.verifier,            // proves we started this exact flow
  });
  // Confidential clients add their secret (Basic auth header preferred over body params):
  const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
  if (process.env.OAUTH_CLIENT_SECRET) {
    const basic = Buffer.from(
      `${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}`
    ).toString('base64');
    headers.Authorization = `Basic ${basic}`;
  }

  const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers, body });
  if (!tokenRes.ok) return res.status(401).send('Token exchange failed');
  const tokens = await tokenRes.json(); // { access_token, refresh_token, id_token, expires_in }

  // (f) Validate the OIDC id_token signature/iss/aud/exp AND that nonce matches saved.nonce
  //     (see §2 verifyToken; pass audience = OAUTH_CLIENT_ID and check decoded.nonce === saved.nonce)
  // (g) Establish your OWN session here (don't hand provider tokens to the browser).
  res.redirect('/');
});

Authorization Code + PKCE — public client (SPA/mobile) caveat

A SPA cannot keep state/verifier truly secret from XSS. sessionStorage survives a redirect but is JS-readable. Preferred 2026 pattern: run the code exchange in a BFF so the browser never holds tokens. If you must do it browser-side, still generate and check state, and still send the code_verifier:

// Browser: begin
const { verifier, challenge } = await pkcePairWebCrypto(); // Web Crypto version below
const state = crypto.randomUUID();
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email profile');
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', state);
location.href = url.toString();

// Browser: callback — VALIDATE state before exchanging
const params = new URLSearchParams(location.search);
const code = params.get('code');
const returnedState = params.get('state');
const savedState = sessionStorage.getItem('oauth_state');
const verifier = sessionStorage.getItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('pkce_verifier');
if (!code || !returnedState || returnedState !== savedState) {
  throw new Error('Invalid OAuth state or missing code'); // stop — do not exchange
}
const res = await fetch(discovery.token_endpoint, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code', code,
    client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code_verifier: verifier,
  }),
});
const tokens = await res.json();

// Web Crypto PKCE (browser)
async function pkcePairWebCrypto() {
  const bytes = crypto.getRandomValues(new Uint8Array(32));
  const verifier = btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
  const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  return { verifier, challenge };
}

Client Credentials Flow (Machine-to-Machine)

For backend services and API-to-API calls. No user. Token requests are form-encoded per RFC 6749, the secret stays server-side, and you should cache the token until shortly before expires_in rather than minting one per call.

let cached = { token: null, exp: 0 };

async function getServiceToken() {
  if (cached.token && Date.now()  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.
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  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 accessToken = jwt.sign(
        { sub: stored.userId, role: stored.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)

3. Session Management

Cookie-Based Sessions (Traditional / BFF)

import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

app.set('trust proxy', 1); // required behind a TLS-terminating proxy so secure cookies work
app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,   // rotate via an array: [newSecret, oldSecret]
  resave: false,
  saveUninitialized: false,
  name: '__Host-session',               // __Host- prefix: requires Secure, path=/, no Domain
  cookie: {
    secure: true,       // HTTPS only
    httpOnly: true,     // no JS access (XSS can't read it)
    sameSite: 'lax',    // mitigates cross-site POST CSRF (not a complete defense — see §10)
    maxAge: 24 * 60 * 60 * 1000,
    path: '/',
    // NOTE: __Host- forbids `domain`. Drop the prefix if you need a shared parent domain.
  },
}));

// Regenerate the session ID on privilege change to prevent session fixation:
app.post('/auth/login', loginLimiter, async (req, res) => {
  // ... verify credentials ...
  req.session.regenerate((err) => {
    if (err) return res.status(500).end();
    req.session.userId = user.id;
    res.json({ ok: true });
  });
});

Cookie vs Token Comparison

| Aspect | Cookie Sessions | JWT Access Tokens | |--------|----------------|-------------------| | Storage | Server (Redis/DB) | Client — see XSS row | | Stateless | No (server lookup) | Yes (self-contained) | | Revocation | Easy (delete from store) | Hard (need blocklist or short TT

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.