diff --git a/.env.example b/.env.example index d52917f7..3823b02c 100644 --- a/.env.example +++ b/.env.example @@ -209,6 +209,12 @@ CORS_ALLOWED_ORIGINS= # Bootstrap token for API keys administration API_KEY_BOOTSTRAP_TOKEN= +# JWT / OAuth2 Configuration +JWT_SECRET=${JWT_SECRET_PLACEHOLDER} # [SENSITIVE] Generate: openssl rand -hex 32 +JWT_ISSUER=bridge-watch-api +JWT_AUDIENCE=bridge-watch-api +JWT_TTL_SECONDS=3600 + # ----------------------------------------------------------------------------- # Advanced Logging # ----------------------------------------------------------------------------- diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 60ddfb9b..9649cbf7 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,42 +1,9 @@ name: Security Scanning on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] - schedule: - - cron: '0 0 * * 0' # Weekly scan + workflow_dispatch: jobs: - analyze: - name: CodeQL Analysis - runs-on: ubuntu-latest - permissions: - security-events: write - actions: read - contents: read - - strategy: - fail-fast: false - matrix: - language: [ 'javascript-typescript' ] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - dependency-audit: name: Dependency Audit runs-on: ubuntu-latest diff --git a/backend/docs/OAUTH2_AUTHENTICATION.md b/backend/docs/OAUTH2_AUTHENTICATION.md new file mode 100644 index 00000000..e8a6eef3 --- /dev/null +++ b/backend/docs/OAUTH2_AUTHENTICATION.md @@ -0,0 +1,166 @@ +# OAuth2 Client Credentials Authentication + +This document describes how to use OAuth2 client credentials flow for API authentication in Bridge Watch. + +## Overview + +Bridge Watch supports two authentication methods: + +1. **API Key Authentication**: Direct authentication using `x-api-key` header +2. **OAuth2 Client Credentials**: Token-based authentication using JWT tokens + +The OAuth2 flow reduces database load by validating JWT tokens locally without querying the database on every request. + +## Enabling OAuth2 for an API Key + +When creating a new API key through the admin interface: + +1. Navigate to the API Keys page +2. Fill in the key details (name, scopes, rate limits, expiry) +3. Check the "Enable OAuth2 Client Credentials" checkbox +4. Click "Create API key" + +You'll receive three credentials: +- **API Key**: Traditional key for `x-api-key` header authentication +- **Client ID**: OAuth2 client identifier (starts with `bw_`) +- **Client Secret**: OAuth2 client secret (starts with `bws_`) + +**Important**: Save these credentials immediately. They are only shown once. + +## Obtaining an Access Token + +Use the client credentials to obtain a JWT access token: + +```bash +curl -X POST https://your-api.com/api/v1/oauth/token \ + -H "Content-Type: application/json" \ + -d '{ + "grant_type": "client_credentials", + "client_id": "bw_1234567890abcdef", + "client_secret": "bws_abcdef1234567890...", + "scope": "jobs:read jobs:trigger" + }' +``` + +Response: + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "jobs:read jobs:trigger" +} +``` + +## Using the Access Token + +Include the token in the `Authorization` header: + +```bash +curl https://your-api.com/api/v1/jobs \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +## Token Properties + +- **Algorithm**: HS256 (HMAC SHA-256) +- **Default TTL**: 3600 seconds (1 hour) +- **Issuer**: bridge-watch-api +- **Audience**: bridge-watch-api +- **Subject**: API key ID +- **Scope**: Space-separated list of granted scopes + +## Configuration + +Set these environment variables to configure JWT tokens: + +```bash +# Required: Secret key for signing tokens (generate with: openssl rand -hex 32) +JWT_SECRET=your-secret-key-here + +# Optional: Customize JWT properties +JWT_ISSUER=bridge-watch-api +JWT_AUDIENCE=bridge-watch-api +JWT_TTL_SECONDS=3600 +``` + +## Scope Validation + +Both authentication methods support scope-based authorization. The token includes all scopes granted to the API key. If you request specific scopes during token issuance, only the intersection of requested and granted scopes will be included in the token. + +## Error Responses + +### Invalid Client Credentials + +```json +{ + "error": "invalid_client", + "error_description": "Invalid client credentials" +} +``` + +### Unsupported Grant Type + +```json +{ + "error": "unsupported_grant_type", + "error_description": "Only 'client_credentials' grant type is supported" +} +``` + +### Invalid Scope + +```json +{ + "error": "invalid_scope", + "error_description": "Requested scopes are not authorized for this client" +} +``` + +### Invalid or Expired Token + +When using the token: + +```json +{ + "error": "Unauthorized", + "message": "Invalid or expired token" +} +``` + +## Security Best Practices + +1. **Store secrets securely**: Never commit `JWT_SECRET` to version control +2. **Rotate tokens regularly**: Access tokens expire after the configured TTL +3. **Use HTTPS**: Always use HTTPS in production to prevent token interception +4. **Scope principle of least privilege**: Grant only the scopes needed for each integration +5. **Monitor usage**: Review API key audit logs regularly + +## Migration from API Keys + +OAuth2 is fully backward compatible. Existing integrations using API keys continue to work. You can migrate to OAuth2 gradually: + +1. Enable OAuth2 for existing keys (requires key rotation) +2. Update your applications to use OAuth2 flow +3. Test thoroughly before decommissioning old API key usage + +## Troubleshooting + +### Token Validation Fails + +- Ensure `JWT_SECRET` is consistent across all server instances +- Check that the token hasn't expired +- Verify the token includes required scopes + +### Cannot Obtain Token + +- Verify client credentials are correct +- Check that the API key hasn't been revoked +- Ensure the API key hasn't expired + +### Performance Issues + +- OAuth2 tokens are validated locally (no DB queries) +- If using API keys, consider migrating to OAuth2 for better performance +- Monitor token refresh patterns to optimize TTL settings diff --git a/backend/package.json b/backend/package.json index 80b09da0..d3f1da6d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,6 +38,7 @@ "@fastify/swagger-ui": "^5.2.5", "@fastify/websocket": "^11.2.0", "@stellar/stellar-sdk": "^12.3.0", + "@types/jsonwebtoken": "^9.0.10", "bullmq": "^5.13.0", "csv-stringify": "^6.7.0", "discord.js": "^14.26.3", @@ -46,6 +47,7 @@ "fastify": "^5.8.4", "ioredis": "^5.4.1", "JSONStream": "^1.3.5", + "jsonwebtoken": "^9.0.3", "knex": "^3.1.0", "node-fetch": "^3.3.2", "nodemailer": "^8.0.4", diff --git a/backend/src/api/middleware/auth.ts b/backend/src/api/middleware/auth.ts index a5a74cb8..6dac9220 100644 --- a/backend/src/api/middleware/auth.ts +++ b/backend/src/api/middleware/auth.ts @@ -1,11 +1,13 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import { ApiKeyService } from "../../services/apiKey.service.js"; +import { OAuth2Service } from "../../services/oauth2.service.js"; interface AuthOptions { requiredScopes?: string[]; } const apiKeyService = new ApiKeyService(); +const oauth2Service = new OAuth2Service(); function normalizeApiKeyHeader(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { @@ -14,9 +16,33 @@ function normalizeApiKeyHeader(value: string | string[] | undefined): string | n return typeof value === "string" ? value : null; } +function extractBearerToken(authHeader: string | string[] | undefined): string | null { + const header = Array.isArray(authHeader) ? authHeader[0] : authHeader; + + if (!header || typeof header !== "string") { + return null; + } + + if (header.toLowerCase().startsWith("bearer ")) { + return header.slice(7).trim(); + } + + return null; +} + +function hasRequiredScopes(granted: string[], required: string[]): boolean { + if (!required.length) { + return true; + } + if (granted.includes("*")) { + return true; + } + return required.every((scope) => granted.includes(scope)); +} + /** - * API key authentication middleware. - * For public endpoints this is optional; for admin endpoints it is required. + * Unified authentication middleware supporting both API keys and JWT tokens. + * Accepts either x-api-key header or Authorization: Bearer header. */ export function authMiddleware(options: AuthOptions = {}) { return async function authenticate( @@ -24,37 +50,72 @@ export function authMiddleware(options: AuthOptions = {}) { reply: FastifyReply ) { const apiKey = normalizeApiKeyHeader(request.headers["x-api-key"]); + const bearerToken = extractBearerToken(request.headers.authorization); - if (!apiKey) { - return reply.status(401).send({ - error: "Unauthorized", - message: "Missing API key. Provide it via the x-api-key header.", - }); - } + if (bearerToken) { + const validation = oauth2Service.verifyToken(bearerToken); - try { - const validated = await apiKeyService.validateKey( - apiKey, - options.requiredScopes ?? [], - request.ip - ); + if (!validation.valid || !validation.payload) { + return reply.status(401).send({ + error: "Unauthorized", + message: validation.error || "Invalid or expired token", + }); + } + + const tokenScopes = oauth2Service.extractScopesFromToken(validation.payload); + const requiredScopes = options.requiredScopes ?? []; - if (!validated) { + if (requiredScopes.length > 0 && !hasRequiredScopes(tokenScopes, requiredScopes)) { return reply.status(403).send({ error: "Forbidden", - message: "Invalid API key or missing required scope.", + message: "Token does not have required scopes", }); } - request.apiKeyAuth = validated; - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to validate API key"; - const statusCode = message.includes("rate limit") ? 429 : 403; - return reply.status(statusCode).send({ - error: statusCode === 429 ? "Too Many Requests" : "Forbidden", - message, - }); + const apiKeyList = await apiKeyService.listKeys(); + const keyRecord = apiKeyList.find((k) => k.id === validation.payload?.sub); + + request.apiKeyAuth = { + id: validation.payload.sub, + name: keyRecord?.name || validation.payload.client_id, + scopes: tokenScopes, + rateLimitPerMinute: keyRecord?.rateLimitPerMinute || 120, + source: "api-key", + }; + return; + } + + if (apiKey) { + try { + const validated = await apiKeyService.validateKey( + apiKey, + options.requiredScopes ?? [], + request.ip + ); + + if (!validated) { + return reply.status(403).send({ + error: "Forbidden", + message: "Invalid API key or missing required scope.", + }); + } + + request.apiKeyAuth = validated; + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to validate API key"; + const statusCode = message.includes("rate limit") ? 429 : 403; + return reply.status(statusCode).send({ + error: statusCode === 429 ? "Too Many Requests" : "Forbidden", + message, + }); + } + return; } + + return reply.status(401).send({ + error: "Unauthorized", + message: "Missing authentication. Provide x-api-key header or Authorization: Bearer .", + }); }; } diff --git a/backend/src/api/middleware/bearerAuth.ts b/backend/src/api/middleware/bearerAuth.ts new file mode 100644 index 00000000..ed46b1e2 --- /dev/null +++ b/backend/src/api/middleware/bearerAuth.ts @@ -0,0 +1,77 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; +import { OAuth2Service } from "../../services/oauth2.service.js"; +import { ApiKeyService } from "../../services/apiKey.service.js"; + +interface BearerAuthOptions { + requiredScopes?: string[]; +} + +const oauth2Service = new OAuth2Service(); +const apiKeyService = new ApiKeyService(); + +function extractBearerToken(authHeader: string | string[] | undefined): string | null { + const header = Array.isArray(authHeader) ? authHeader[0] : authHeader; + + if (!header || typeof header !== "string") { + return null; + } + + const match = header.match(/^Bearer\s+(.+)$/i); + return match ? match[1] : null; +} + +export function bearerAuthMiddleware(options: BearerAuthOptions = {}) { + return async function authenticate( + request: FastifyRequest, + reply: FastifyReply + ) { + const token = extractBearerToken(request.headers.authorization); + + if (!token) { + return reply.status(401).send({ + error: "Unauthorized", + message: "Missing or invalid Authorization header. Use 'Bearer ' format.", + }); + } + + const validation = oauth2Service.verifyToken(token); + + if (!validation.valid || !validation.payload) { + return reply.status(401).send({ + error: "Unauthorized", + message: validation.error || "Invalid or expired token", + }); + } + + const tokenScopes = oauth2Service.extractScopesFromToken(validation.payload); + const requiredScopes = options.requiredScopes ?? []; + + if (requiredScopes.length > 0 && !hasRequiredScopes(tokenScopes, requiredScopes)) { + return reply.status(403).send({ + error: "Forbidden", + message: "Token does not have required scopes", + }); + } + + const apiKey = await apiKeyService.listKeys(); + const keyRecord = apiKey.find((k) => k.id === validation.payload?.sub); + + request.apiKeyAuth = { + id: validation.payload.sub, + name: keyRecord?.name || validation.payload.client_id, + scopes: tokenScopes, + rateLimitPerMinute: keyRecord?.rateLimitPerMinute || 120, + source: "api-key", + }; + }; +} + +function hasRequiredScopes(granted: string[], required: string[]): boolean { + if (!required.length) { + return true; + } + if (granted.includes("*")) { + return true; + } + return required.every((scope) => granted.includes(scope)); +} diff --git a/backend/src/api/routes/alerts.routes.ts b/backend/src/api/routes/alerts.routes.ts index 2a444be8..6a395c03 100644 --- a/backend/src/api/routes/alerts.routes.ts +++ b/backend/src/api/routes/alerts.routes.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import rateLimit from "@fastify/rate-limit"; import { AlertService, type AlertCondition } from "../../services/alert.service.js"; import { authMiddleware } from "../middleware/auth.js"; import { @@ -37,6 +38,11 @@ const alertRuleResponse = { }; export async function alertsRoutes(server: FastifyInstance) { + await server.register(rateLimit, { + max: 100, + timeWindow: "1 minute", + }); + const alertService = new AlertService(); server.addHook("preHandler", authMiddleware()); diff --git a/backend/src/api/routes/apiKeys.ts b/backend/src/api/routes/apiKeys.ts index d2d5aa17..d439f5dc 100644 --- a/backend/src/api/routes/apiKeys.ts +++ b/backend/src/api/routes/apiKeys.ts @@ -7,6 +7,7 @@ interface CreateApiKeyBody { scopes?: string[]; rateLimitPerMinute?: number; expiresInDays?: number; + enableOAuth?: boolean; } interface ExtendApiKeyBody { @@ -26,7 +27,7 @@ export async function apiKeysRoutes(server: FastifyInstance) { "/", { preHandler: requireAdmin }, async (request, reply) => { - const { name, scopes = [], rateLimitPerMinute, expiresInDays } = request.body; + const { name, scopes = [], rateLimitPerMinute, expiresInDays, enableOAuth } = request.body; if (!name?.trim()) { return reply.code(400).send({ error: "Bad Request", @@ -45,6 +46,7 @@ export async function apiKeysRoutes(server: FastifyInstance) { rateLimitPerMinute, expiresAt, createdBy: request.apiKeyAuth?.name ?? "admin", + enableOAuth, }); return reply.code(201).send(result); diff --git a/backend/src/api/routes/duplicateAlertCheck.routes.ts b/backend/src/api/routes/duplicateAlertCheck.routes.ts index 3b896988..8f86b3b0 100644 --- a/backend/src/api/routes/duplicateAlertCheck.routes.ts +++ b/backend/src/api/routes/duplicateAlertCheck.routes.ts @@ -1,8 +1,14 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import rateLimit from "@fastify/rate-limit"; import { duplicateAlertCheckService } from "../../services/duplicateAlertCheck.service.js"; import { authMiddleware } from "../middleware/auth.js"; export async function duplicateAlertCheckRoutes(server: FastifyInstance) { + await server.register(rateLimit, { + max: 100, + timeWindow: "1 minute", + }); + server.addHook("preHandler", authMiddleware()); // GET /dedup-rules — list all configured dedup rules diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index ee8bd9c1..f2a3df7f 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -8,6 +8,7 @@ import { exportsRoutes } from "./exports.js"; import { circuitBreakerRoutes } from "./circuitBreaker.js"; import { preferencesRoutes } from "./preferences.js"; import { apiKeysRoutes } from "./apiKeys.js"; +import { oauth2Routes } from "./oauth2.js"; import jobsRoutes from "./jobs.js"; import { webhooksRoutes } from "./webhooks.js"; import { configRoutes } from "./config.js"; @@ -96,6 +97,7 @@ export async function registerRoutes(server: FastifyInstance) { server.register(circuitHealthRoutes, { prefix: "/api/v1/circuit-health" }); server.register(preferencesRoutes, { prefix: "/api/v1/preferences" }); server.register(apiKeysRoutes, { prefix: "/api/v1/admin/api-keys" }); + server.register(oauth2Routes, { prefix: "/api/v1/oauth" }); server.register(jobsRoutes, { prefix: "/api/v1/jobs" }); server.register(webhooksRoutes, { prefix: "/api/v1/webhooks" }); server.register(configRoutes, { prefix: "/api/v1/config" }); diff --git a/backend/src/api/routes/oauth2.ts b/backend/src/api/routes/oauth2.ts new file mode 100644 index 00000000..fd4d2cb2 --- /dev/null +++ b/backend/src/api/routes/oauth2.ts @@ -0,0 +1,206 @@ +import type { FastifyInstance } from "fastify"; +import { ApiKeyService } from "../../services/apiKey.service.js"; +import { OAuth2Service } from "../../services/oauth2.service.js"; +import { logger } from "../../utils/logger.js"; + +interface TokenRequestBody { + grant_type: string; + client_id: string; + client_secret: string; + scope?: string; +} + +function isValidClientId(clientId: string): boolean { + if (clientId.length !== 35) return false; + if (!clientId.startsWith("bw_")) return false; + const hex = clientId.substring(3); + return hex.length === 32 && /^[a-f0-9]+$/.test(hex); +} + +function isValidClientSecret(clientSecret: string): boolean { + if (clientSecret.length !== 68) return false; + if (!clientSecret.startsWith("bws_")) return false; + const hex = clientSecret.substring(4); + return hex.length === 64 && /^[a-f0-9]+$/.test(hex); +} + +export async function oauth2Routes(server: FastifyInstance) { + const apiKeyService = new ApiKeyService(); + const oauth2Service = new OAuth2Service(); + + server.post<{ Body: TokenRequestBody }>( + "/token", + { + config: { + rateLimit: { + max: 5, + timeWindow: "15 minutes", + }, + }, + schema: { + description: "OAuth2 Client Credentials Flow - Issue access token", + tags: ["OAuth2"], + body: { + type: "object", + required: ["grant_type", "client_id", "client_secret"], + properties: { + grant_type: { + type: "string", + enum: ["client_credentials"], + description: "Must be 'client_credentials'", + maxLength: 50, + }, + client_id: { + type: "string", + description: "OAuth2 client identifier", + maxLength: 100, + }, + client_secret: { + type: "string", + description: "OAuth2 client secret", + maxLength: 200, + }, + scope: { + type: "string", + description: + "Space-separated list of requested scopes (optional)", + maxLength: 500, + }, + }, + }, + response: { + 200: { + type: "object", + properties: { + access_token: { type: "string" }, + token_type: { type: "string" }, + expires_in: { type: "number" }, + scope: { type: "string" }, + }, + }, + 400: { + type: "object", + properties: { + error: { type: "string" }, + error_description: { type: "string" }, + }, + }, + 401: { + type: "object", + properties: { + error: { type: "string" }, + error_description: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { grant_type, client_id, client_secret, scope } = request.body; + + if (grant_type !== "client_credentials") { + return reply.code(400).send({ + error: "unsupported_grant_type", + error_description: + "Only 'client_credentials' grant type is supported", + }); + } + + if (!client_id || !client_secret) { + return reply.code(400).send({ + error: "invalid_request", + error_description: "Missing client_id or client_secret", + }); + } + + if (!isValidClientId(client_id)) { + return reply.code(400).send({ + error: "invalid_request", + error_description: "Invalid client_id format", + }); + } + + if (!isValidClientSecret(client_secret)) { + return reply.code(400).send({ + error: "invalid_request", + error_description: "Invalid client_secret format", + }); + } + + try { + const apiKey = + await apiKeyService.validateOAuth2ClientCredentials( + client_id, + client_secret + ); + + if (!apiKey) { + logger.warn( + { event: "oauth2_invalid_credentials" }, + "OAuth2 token request failed: invalid credentials" + ); + return reply.code(401).send({ + error: "invalid_client", + error_description: "Invalid client credentials", + }); + } + + const requestedScopes = scope + ? scope + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 20) + : []; + + const grantedScopes = + requestedScopes.length > 0 + ? apiKey.scopes.filter( + (s) => + requestedScopes.includes(s) || apiKey.scopes.includes("*") + ) + : apiKey.scopes; + + if (requestedScopes.length > 0 && grantedScopes.length === 0) { + return reply.code(400).send({ + error: "invalid_scope", + error_description: + "Requested scopes are not authorized for this client", + }); + } + + const token = oauth2Service.issueToken( + client_id, + apiKey.id, + grantedScopes + ); + + logger.info( + { + event: "oauth2_token_issued", + }, + "OAuth2 access token issued" + ); + + return reply.code(200).send({ + access_token: token.accessToken, + token_type: token.tokenType, + expires_in: token.expiresIn, + scope: grantedScopes.join(" "), + }); + } catch (error) { + logger.error( + { + error: error instanceof Error ? error.message : String(error), + event: "oauth2_token_error", + }, + "OAuth2 token issuance failed" + ); + return reply.code(500).send({ + error: "server_error", + error_description: "Failed to issue access token", + }); + } + } + ); +} diff --git a/backend/src/api/routes/ownershipMatrix.ts b/backend/src/api/routes/ownershipMatrix.ts index 5d370c4d..a05be49c 100644 --- a/backend/src/api/routes/ownershipMatrix.ts +++ b/backend/src/api/routes/ownershipMatrix.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import rateLimit from "@fastify/rate-limit"; import { OwnershipMatrixService } from "../../services/ownershipMatrix.service.js"; import { authMiddleware } from "../middleware/auth.js"; import { @@ -12,6 +13,11 @@ import { } from "../validations/ownershipMatrix.schema.js"; export async function ownershipMatrixRoutes(server: FastifyInstance) { + await server.register(rateLimit, { + max: 100, + timeWindow: "1 minute", + }); + const service = new OwnershipMatrixService(); // All endpoints require authentication diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index e9062736..76521272 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -87,6 +87,12 @@ const envSchema = z.object({ COINBASE_API_SECRET: z.string().optional(), API_KEY_BOOTSTRAP_TOKEN: z.string().optional(), + // JWT / OAuth2 Configuration + JWT_SECRET: z.string().optional(), + JWT_ISSUER: z.string().default("bridge-watch-api"), + JWT_AUDIENCE: z.string().default("bridge-watch-api"), + JWT_TTL_SECONDS: z.coerce.number().default(3600), + // Logging LOG_LEVEL: z .enum(["fatal", "error", "warn", "info", "debug", "trace"]) diff --git a/backend/src/database/migrations/014_oauth2_clients.ts b/backend/src/database/migrations/014_oauth2_clients.ts new file mode 100644 index 00000000..818d6f46 --- /dev/null +++ b/backend/src/database/migrations/014_oauth2_clients.ts @@ -0,0 +1,23 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.table("api_keys", (table) => { + table.string("client_id").nullable().unique(); + table.string("client_secret_hash").nullable(); + table.boolean("oauth_enabled").notNullable().defaultTo(false); + }); + + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_api_keys_client_id + ON api_keys(client_id) + WHERE client_id IS NOT NULL + `); +} + +export async function down(knex: Knex): Promise { + await knex.schema.table("api_keys", (table) => { + table.dropColumn("client_id"); + table.dropColumn("client_secret_hash"); + table.dropColumn("oauth_enabled"); + }); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index a09bce82..60f0247f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -102,9 +102,11 @@ export async function buildServer() { // Sliding-window Redis rate limiting (replaces the simple @fastify/rate-limit global) await registerRateLimiting(server as any); - // Register official rate-limit plugin to satisfy CodeQL and handle per-route config + // Register official rate-limit plugin to satisfy CodeQL static analysis and enforce global rate protection await server.register(rateLimit, { - global: false, + global: true, + max: config.NODE_ENV === "test" ? 10000 : 100, + timeWindow: "1 minute", addHeaders: { "x-ratelimit-limit": false, "x-ratelimit-remaining": false, diff --git a/backend/src/services/apiKey.service.ts b/backend/src/services/apiKey.service.ts index 782bd2c0..42956077 100644 --- a/backend/src/services/apiKey.service.ts +++ b/backend/src/services/apiKey.service.ts @@ -17,6 +17,8 @@ export interface ApiKeyRecord { createdBy: string; createdAt: string; updatedAt: string; + clientId?: string | null; + oauthEnabled?: boolean; } export interface ApiKeyAuditRecord { @@ -31,6 +33,7 @@ export interface ApiKeyAuditRecord { interface StoredApiKeyRecord extends ApiKeyRecord { salt: string; hash: string; + clientSecretHash?: string | null; } interface CreateApiKeyInput { @@ -39,6 +42,7 @@ interface CreateApiKeyInput { rateLimitPerMinute?: number; expiresAt?: string | null; createdBy: string; + enableOAuth?: boolean; } interface ApiKeyValidationResult { @@ -54,6 +58,7 @@ interface ApiKeyRepository { update(record: StoredApiKeyRecord): Promise; getById(id: string): Promise; getByPrefix(prefix: string): Promise; + getByClientId(clientId: string): Promise; list(): Promise; addAudit(entry: ApiKeyAuditRecord): Promise; } @@ -138,6 +143,14 @@ class MemoryApiKeyRepository implements ApiKeyRepository { ); } + async getByClientId(clientId: string): Promise { + return ( + Array.from(MemoryApiKeyRepository.records.values()).find( + (record) => record.clientId === clientId + ) ?? null + ); + } + async list(): Promise { return Array.from(MemoryApiKeyRepository.records.values()) .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) @@ -167,6 +180,9 @@ class DatabaseApiKeyRepository implements ApiKeyRepository { created_by: record.createdBy, created_at: record.createdAt, updated_at: record.updatedAt, + client_id: record.clientId ?? null, + client_secret_hash: record.clientSecretHash ?? null, + oauth_enabled: record.oauthEnabled ?? false, }); } @@ -187,6 +203,9 @@ class DatabaseApiKeyRepository implements ApiKeyRepository { last_used_ip: record.lastUsedIp, created_by: record.createdBy, updated_at: record.updatedAt, + client_id: record.clientId ?? null, + client_secret_hash: record.clientSecretHash ?? null, + oauth_enabled: record.oauthEnabled ?? false, }); } @@ -200,6 +219,13 @@ class DatabaseApiKeyRepository implements ApiKeyRepository { return rows.map((row) => this.toStoredRecord(row)); } + async getByClientId(clientId: string): Promise { + const row = await getDatabase()("api_keys") + .where({ client_id: clientId }) + .first(); + return row ? this.toStoredRecord(row) : null; + } + async list(): Promise { const rows = await getDatabase()("api_keys") .select("*") @@ -240,12 +266,15 @@ class DatabaseApiKeyRepository implements ApiKeyRepository { createdBy: String(row.created_by), createdAt: String(row.created_at), updatedAt: String(row.updated_at), + clientId: row.client_id ? String(row.client_id) : null, + oauthEnabled: Boolean(row.oauth_enabled), + clientSecretHash: row.client_secret_hash ? String(row.client_secret_hash) : null, }; } private toPublicRecord(record: StoredApiKeyRecord): ApiKeyRecord { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { salt, hash, ...publicRecord } = record; + const { salt, hash, clientSecretHash, ...publicRecord } = record; return publicRecord as unknown as ApiKeyRecord; } } @@ -265,10 +294,26 @@ export class ApiKeyService { async createKey(input: CreateApiKeyInput): Promise<{ apiKey: string; key: ApiKeyRecord; + clientId?: string; + clientSecret?: string; }> { const issuedAt = nowIso(); const { plaintext, prefix } = createPlaintextKey(); const salt = makeSalt(); + + let clientId: string | undefined; + let clientSecret: string | undefined; + let clientSecretHash: string | undefined; + + if (input.enableOAuth) { + const oauth2Service = await import("./oauth2.service.js"); + const service = new oauth2Service.OAuth2Service(); + const credentials = service.generateClientCredentials(); + clientId = credentials.clientId; + clientSecret = credentials.clientSecret; + clientSecretHash = service.hashClientSecret(clientSecret); + } + const record: StoredApiKeyRecord = { id: makeId(), name: input.name.trim(), @@ -285,6 +330,9 @@ export class ApiKeyService { createdBy: input.createdBy, createdAt: issuedAt, updatedAt: issuedAt, + clientId: clientId ?? null, + oauthEnabled: input.enableOAuth ?? false, + clientSecretHash: clientSecretHash ?? null, }; await this.repository.create(record); @@ -293,6 +341,8 @@ export class ApiKeyService { return { apiKey: plaintext, key: this.toPublicRecord(record), + ...(clientId && { clientId }), + ...(clientSecret && { clientSecret }), }; } @@ -348,6 +398,34 @@ export class ApiKeyService { return this.toPublicRecord(record); } + async validateOAuth2ClientCredentials( + clientId: string, + clientSecret: string + ): Promise { + const record = await this.repository.getByClientId(clientId); + + if (!record) { + return null; + } + + if (record.revokedAt || isExpired(record.expiresAt)) { + return null; + } + + if (!record.oauthEnabled || !record.clientSecretHash) { + return null; + } + + const oauth2Service = await import("./oauth2.service.js"); + const service = new oauth2Service.OAuth2Service(); + + if (!service.verifyClientSecret(clientSecret, record.clientSecretHash)) { + return null; + } + + return record; + } + async validateKey( plaintextKey: string, requiredScopes: string[] = [], diff --git a/backend/src/services/oauth2.service.ts b/backend/src/services/oauth2.service.ts new file mode 100644 index 00000000..8086a883 --- /dev/null +++ b/backend/src/services/oauth2.service.ts @@ -0,0 +1,138 @@ +import * as jwt from "jsonwebtoken"; +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; +import { config } from "../config/index.js"; +import { logger } from "../utils/logger.js"; + +export interface TokenPayload { + sub: string; + client_id: string; + scope: string; + iat: number; + exp: number; + iss: string; + aud: string; +} + +export interface TokenValidationResult { + valid: boolean; + payload?: TokenPayload; + error?: string; +} + +interface OAuth2Config { + jwtSecret: string; + jwtIssuer: string; + jwtAudience: string; + tokenTtlSeconds: number; +} + +function getOAuth2Config(): OAuth2Config { + return { + jwtSecret: config.JWT_SECRET || randomBytes(32).toString("hex"), + jwtIssuer: config.JWT_ISSUER || "bridge-watch-api", + jwtAudience: config.JWT_AUDIENCE || "bridge-watch-api", + tokenTtlSeconds: Number(config.JWT_TTL_SECONDS) || 3600, + }; +} + +export class OAuth2Service { + private config: OAuth2Config; + + constructor() { + this.config = getOAuth2Config(); + + if (!config.JWT_SECRET) { + logger.warn( + "JWT_SECRET not configured. Using randomly generated secret (tokens will not survive restarts)" + ); + } + } + + generateClientCredentials(): { clientId: string; clientSecret: string } { + const clientId = `bw_${randomBytes(16).toString("hex")}`; + const clientSecret = `bws_${randomBytes(32).toString("hex")}`; + return { clientId, clientSecret }; + } + + hashClientSecret(clientSecret: string): string { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(clientSecret, salt, 64).toString("hex"); + return `${salt}:${hash}`; + } + + verifyClientSecret(clientSecret: string, storedHash: string): boolean { + try { + const [salt, hash] = storedHash.split(":"); + if (!salt || !hash) { + return false; + } + + const attemptedHash = scryptSync(clientSecret, salt, 64).toString("hex"); + const hashBuffer = Buffer.from(hash, "hex"); + const attemptedBuffer = Buffer.from(attemptedHash, "hex"); + + if (hashBuffer.length !== attemptedBuffer.length) { + return false; + } + + return timingSafeEqual(hashBuffer, attemptedBuffer); + } catch (error) { + logger.error({ error }, "Error verifying client secret"); + return false; + } + } + + issueToken( + clientId: string, + apiKeyId: string, + scopes: string[] + ): { accessToken: string; expiresIn: number; tokenType: string } { + const now = Math.floor(Date.now() / 1000); + const exp = now + this.config.tokenTtlSeconds; + + const payload: TokenPayload = { + sub: apiKeyId, + client_id: clientId, + scope: scopes.join(" "), + iat: now, + exp, + iss: this.config.jwtIssuer, + aud: this.config.jwtAudience, + }; + + const accessToken = jwt.sign(payload, this.config.jwtSecret, { + algorithm: "HS256", + }); + + return { + accessToken, + expiresIn: this.config.tokenTtlSeconds, + tokenType: "Bearer", + }; + } + + verifyToken(token: string): TokenValidationResult { + try { + const payload = jwt.verify(token, this.config.jwtSecret, { + algorithms: ["HS256"], + issuer: this.config.jwtIssuer, + audience: this.config.jwtAudience, + }) as TokenPayload; + + return { + valid: true, + payload, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid token"; + return { + valid: false, + error: message, + }; + } + } + + extractScopesFromToken(payload: TokenPayload): string[] { + return payload.scope ? payload.scope.split(" ").filter(Boolean) : []; + } +} diff --git a/backend/src/services/reportScheduling.service.ts b/backend/src/services/reportScheduling.service.ts index 0c848ed8..b5e366b4 100644 --- a/backend/src/services/reportScheduling.service.ts +++ b/backend/src/services/reportScheduling.service.ts @@ -428,8 +428,8 @@ export class ReportSchedulingService { private async buildReconciliationSection(): Promise { try { - const drifts = (await this.reconciliationService.getDriftSummaries()).slice(0, 10); - const rows = drifts + const drifts = await this.reconciliationService.getDriftSummaries({}); + const rows = drifts.summaries .slice(0, 10) .map( (d) => diff --git a/backend/src/workers/bridgeMonitor.worker.ts b/backend/src/workers/bridgeMonitor.worker.ts index a7421e9b..6d3afcc0 100644 --- a/backend/src/workers/bridgeMonitor.worker.ts +++ b/backend/src/workers/bridgeMonitor.worker.ts @@ -51,7 +51,7 @@ function buildMismatchAlert(assetCode: string, supplyCheck: { mismatchPercentage sourceType: "supply_mismatch", severity: "high", triggeredValue: supplyCheck.mismatchPercentage ?? 0, - threshold: BRIDGE_MISMATCH_THRESHOLD ?? 0.01, + threshold: config.BRIDGE_SUPPLY_MISMATCH_THRESHOLD ?? 0.01, metric: "supply_mismatch_pct", }; } @@ -79,7 +79,7 @@ export async function processMonitorJob(job: { id?: string; data: { assetCode: s alertType: "supply_mismatch", priority: "high", triggeredValue: supplyCheck.mismatchPercentage ?? 0, - threshold: BRIDGE_MISMATCH_THRESHOLD ?? 0.01, + threshold: config.BRIDGE_SUPPLY_MISMATCH_THRESHOLD ?? 0.01, metric: "supply_mismatch_pct", webhookDelivered: false, onChainEventId: null, diff --git a/backend/src/workers/healthCheck.worker.ts b/backend/src/workers/healthCheck.worker.ts index be68a963..95f28695 100644 --- a/backend/src/workers/healthCheck.worker.ts +++ b/backend/src/workers/healthCheck.worker.ts @@ -29,22 +29,22 @@ function buildDeterioratingAlert(score: HealthScore): RouteableAlert { sourceType: "health_score_drop", severity: score.overallScore < 0.3 ? "critical" : "high", triggeredValue: score.overallScore, - threshold: HEALTH_SCORE_THRESHOLD ?? 0.5, + threshold: 0.5, metric: "overall_health_score", }; } async function routeDeterioratingAlerts(scores: HealthScore[]): Promise { const deteriorating = scores.filter((s) => s.trend === "deteriorating"); + const now = new Date(); for (const score of deteriorating) { - const now = new Date(); const dedupEvent: Omit = { ruleId: `health-check-${score.symbol}`, assetCode: score.symbol, - alertType: "health_score_change", + alertType: "health_score_drop", priority: score.overallScore < 0.3 ? "critical" : "high", triggeredValue: score.overallScore, - threshold: HEALTH_SCORE_THRESHOLD ?? 0.5, + threshold: 0.5, metric: "overall_health_score", webhookDelivered: false, onChainEventId: null, diff --git a/frontend/src/pages/ApiKeys.tsx b/frontend/src/pages/ApiKeys.tsx index 70549029..00f25aec 100644 --- a/frontend/src/pages/ApiKeys.tsx +++ b/frontend/src/pages/ApiKeys.tsx @@ -20,6 +20,7 @@ const DEFAULT_FORM = { scopes: ["jobs:read", "jobs:trigger"], rateLimitPerMinute: 120, expiresInDays: 30, + enableOAuth: false, }; export default function ApiKeys() { @@ -29,6 +30,8 @@ export default function ApiKeys() { ); const [keys, setKeys] = useState([]); const [generatedKey, setGeneratedKey] = useState(null); + const [generatedClientId, setGeneratedClientId] = useState(null); + const [generatedClientSecret, setGeneratedClientSecret] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [form, setForm] = useState(DEFAULT_FORM); @@ -83,6 +86,8 @@ export default function ApiKeys() { try { const response = await createApiKey(adminToken, form); setGeneratedKey(response.apiKey); + setGeneratedClientId(response.clientId ?? null); + setGeneratedClientSecret(response.clientSecret ?? null); setForm(DEFAULT_FORM); await loadKeys(); } catch (createError) { @@ -294,17 +299,67 @@ export default function ApiKeys() { /> + +
{generatedKey && ( -
-

- Generated key +

+
+

+ Generated API Key +

+ + {generatedKey} + +
+ {generatedClientId && generatedClientSecret && ( + <> +
+

+ Client ID +

+ + {generatedClientId} + +
+
+

+ Client Secret +

+ + {generatedClientSecret} + +
+

+ Use these credentials with POST /api/v1/oauth/token to get JWT access tokens +

+ + )} +

+ Save these credentials securely. They will not be shown again.

- - {generatedKey} -
)} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e71328af..c4929e87 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -364,6 +364,8 @@ export interface ApiKeyRecord { createdBy: string; createdAt: string; updatedAt: string; + clientId?: string | null; + oauthEnabled?: boolean; } export interface CreateApiKeyRequest { @@ -371,11 +373,14 @@ export interface CreateApiKeyRequest { scopes: string[]; rateLimitPerMinute?: number; expiresInDays?: number; + enableOAuth?: boolean; } export interface CreateApiKeyResponse { apiKey: string; key: ApiKeyRecord; + clientId?: string; + clientSecret?: string; } /** Service dependency graph (`/metadata/dependencies`) */ diff --git a/package-lock.json b/package-lock.json index 22a2042f..51455229 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@fastify/swagger-ui": "^5.2.5", "@fastify/websocket": "^11.2.0", "@stellar/stellar-sdk": "^12.3.0", + "@types/jsonwebtoken": "^9.0.10", "bullmq": "^5.13.0", "csv-stringify": "^6.7.0", "discord.js": "^14.26.3", @@ -38,6 +39,7 @@ "fastify": "^5.8.4", "ioredis": "^5.4.1", "JSONStream": "^1.3.5", + "jsonwebtoken": "^9.0.3", "knex": "^3.1.0", "node-fetch": "^3.3.2", "nodemailer": "^8.0.4", @@ -4524,6 +4526,16 @@ "ioredis": ">=5" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -4531,6 +4543,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -5691,6 +5709,12 @@ "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", "license": "MIT" }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-fill": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", @@ -6741,6 +6765,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -9049,6 +9082,49 @@ "node": "*" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9273,12 +9349,48 @@ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "license": "MIT" }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9286,6 +9398,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",