Agent Skills: Miro Security Basics

|

UncategorizedID: jeremylongshore/claude-code-plugins-plus-skills/miro-security-basics

Install this agent skill to your local

pnpm dlx add-skill https://github.com/jeremylongshore/claude-code-plugins-plus-skills/tree/HEAD/plugins/saas-packs/miro-pack/skills/miro-security-basics

Skill Files

Browse the full folder contents for miro-security-basics.

Download Skill

Loading file tree…

plugins/saas-packs/miro-pack/skills/miro-security-basics/SKILL.md

Skill Metadata

Name
miro-security-basics
Description
|

Miro Security Basics

Overview

Security best practices for Miro OAuth 2.0 tokens, webhook signatures, and access control across the REST API v2.

Prerequisites

  • Miro app created at https://developers.miro.com
  • Understanding of OAuth 2.0 concepts
  • Secret management solution for production

OAuth Token Security

Never Store Tokens in Code

# .env (NEVER commit to git)
MIRO_CLIENT_ID=3458764500000001
MIRO_CLIENT_SECRET=your_client_secret_here
MIRO_ACCESS_TOKEN=eyJ...
MIRO_REFRESH_TOKEN=eyJ...

# .gitignore — MUST include these
.env
.env.local
.env.*.local
*.pem

Scope Minimization

Request only the scopes your app actually needs. Fewer scopes = smaller blast radius if a token is compromised.

| Use Case | Minimum Scopes | |----------|---------------| | Read-only dashboard | boards:read | | Board automation | boards:read, boards:write | | Team management | boards:read, team:read, team:write | | Enterprise admin | boards:read, organizations:read, auditlogs:read | | Full integration | boards:read, boards:write, identity:read |

Token Lifecycle Management

// src/miro/token-manager.ts

interface TokenInfo {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;  // Unix timestamp in ms
  scopes: string[];
}

class MiroTokenManager {
  constructor(
    private storage: TokenStorage,   // DB, Redis, or Vault
    private clientId: string,
    private clientSecret: string,
  ) {}

  async getValidToken(userId: string): Promise<string> {
    const info = await this.storage.get(userId);
    if (!info) throw new Error('User not authorized');

    // Refresh 5 minutes before expiry
    if (Date.now() > info.expiresAt - 300_000) {
      return this.refreshToken(userId, info.refreshToken);
    }

    return info.accessToken;
  }

  private async refreshToken(userId: string, refreshToken: string): Promise<string> {
    const response = await fetch('https://api.miro.com/v1/oauth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: this.clientId,
        client_secret: this.clientSecret,
        refresh_token: refreshToken,
      }),
    });

    if (!response.ok) {
      // Refresh token revoked or expired — user must re-authorize
      await this.storage.delete(userId);
      throw new Error('Miro refresh token invalid. User must re-authorize.');
    }

    const data = await response.json();
    await this.storage.set(userId, {
      accessToken: data.access_token,
      refreshToken: data.refresh_token,
      expiresAt: Date.now() + data.expires_in * 1000,
      scopes: data.scope.split(' '),
    });

    return data.access_token;
  }
}

Webhook Signature Validation

Miro signs webhook payloads so you can verify they originate from Miro's servers.

import crypto from 'crypto';

function verifyMiroWebhookSignature(
  rawBody: Buffer | string,
  signature: string,
  secret: string,
): boolean {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // Timing-safe comparison to prevent timing attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature, 'hex'),
      Buffer.from(expected, 'hex'),
    );
  } catch {
    return false; // Different lengths = not equal
  }
}

// Express middleware
app.post('/webhooks/miro',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-miro-signature'] as string;
    if (!signature || !verifyMiroWebhookSignature(req.body, signature, process.env.MIRO_WEBHOOK_SECRET!)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(req.body.toString());
    // Process verified event...
    res.status(200).json({ received: true });
  }
);

Client Secret Rotation

# Step 1: Generate new secret in Miro app settings
# https://developers.miro.com > Your apps > Select app > App Credentials

# Step 2: Update secret in your environment
# For production (example with GCP Secret Manager):
gcloud secrets versions add miro-client-secret \
  --data-file=<(echo -n "new_secret_value")

# Step 3: Verify new secret works
curl -X POST https://api.miro.com/v1/oauth/token \
  -d "grant_type=refresh_token" \
  -d "client_id=$MIRO_CLIENT_ID" \
  -d "client_secret=NEW_SECRET" \
  -d "refresh_token=$MIRO_REFRESH_TOKEN"

# Step 4: Revoke old secret in Miro app settings

Request Signing for Audit Trails

interface MiroAuditEntry {
  timestamp: string;
  userId: string;
  endpoint: string;
  method: string;
  boardId?: string;
  requestId?: string;  // From X-Request-Id response header
  status: number;
}

async function auditedMiroFetch(
  userId: string,
  path: string,
  options: RequestInit = {},
): Promise<Response> {
  const response = await fetch(`https://api.miro.com${path}`, {
    ...options,
    headers: {
      'Authorization': `Bearer ${await tokenManager.getValidToken(userId)}`,
      'Content-Type': 'application/json',
      ...options.headers,
    },
  });

  const audit: MiroAuditEntry = {
    timestamp: new Date().toISOString(),
    userId,
    endpoint: path,
    method: options.method ?? 'GET',
    boardId: path.match(/boards\/([^/]+)/)?.[1],
    requestId: response.headers.get('X-Request-Id') ?? undefined,
    status: response.status,
  };

  // Log audit trail (never log token or request body)
  console.log('[MIRO_AUDIT]', JSON.stringify(audit));

  return response;
}

Security Checklist

  • [ ] Access tokens stored in environment variables or secret manager, never in code
  • [ ] .env files in .gitignore
  • [ ] OAuth scopes minimized per environment
  • [ ] Webhook signatures validated with timing-safe comparison
  • [ ] Token refresh handled before expiry (5-minute buffer)
  • [ ] Failed refresh triggers re-authorization flow
  • [ ] Client secret rotation procedure documented and tested
  • [ ] Audit logging captures endpoint, method, user, and status (never tokens)
  • [ ] X-Request-Id captured for support ticket correlation

Error Handling

| Security Issue | Detection | Mitigation | |----------------|-----------|------------| | Token in logs | Log audit | Redact Authorization headers in logging middleware | | Token in git | Pre-commit hook / secret scanning | Rotate immediately, revoke old token | | Webhook forgery | Signature validation fails | Return 401, alert security team | | Excessive scopes | Scope audit | Reduce to minimum needed per endpoint |

Resources

Next Steps

For production deployment, see miro-prod-checklist.