diff --git a/.env.prod.sample b/.env.prod.sample new file mode 100644 index 0000000..8545904 --- /dev/null +++ b/.env.prod.sample @@ -0,0 +1,36 @@ +# Production Environment Configuration +# This file serves as a template for GitHub Secrets configuration +# +# IMPORTANT: Secrets are injected directly into Docker containers via environment +# variables. No .env files are created on the host system for security. + +# Domain Configuration +DOMAIN= + +# Container Registry Configuration +FRONTEND_IMAGE= +BACKEND_IMAGE= + +# Database Configuration (External PostgreSQL) +PGUSER= +PGPASSWORD= +PGHOST= +PGDATABASE= +PGPORT= + +# Security Keys (Store as GitHub Secrets) +ENCRYPTION_KEY= +JWT_SECRET= + +# RSKY Configuration +RSKY_FEEDGEN= +RSKY_API_KEY=your_rsky_api_key + +# Fixed Configuration (automatically set in deployment) +PORT= +CLIENT_URL= +BASE_URL= +BSKY_BASE_API_URL=https://api.bsky.app +MUTE_LIST_URI= +MUTE_LIST_ADMIN_DID= +RECONCILIATION_INTERVAL_MS= diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..84a0352 --- /dev/null +++ b/.env.test @@ -0,0 +1,15 @@ +PORT=3000 +NODE_ENV=test +PGHOST=db +PGUSER=postgres +PGPASSWORD=testpassword +PGDATABASE=safeskiesdb +PGPORT=5432 +JWT_SECRET=test-jwt-secret +CLIENT_URL=http://localhost:3000 +BASE_URL=http://localhost:3000 +MUTE_LIST_URI=test-uri +MUTE_LIST_ADMIN_DID=test-did +BSKY_BASE_API_URL=https://bsky.social +RSKY_FEEDGEN=http://localhost:8080 +RSKY_API_KEY=test-key diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..45931c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI - Validate Changes + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run type checking + run: npm run type-check diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..5de6847 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Deploy to Production + +on: + push: + branches: [main] + +env: + IMAGE_NAME: safe-skies-api + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install doctl + uses: digitalocean/action-doctl@v2 + with: + token: ${{ secrets.DO_REGISTRY_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DO_REGISTRY_URL }} + tags: | + type=sha,prefix={{branch}}- + type=raw,value=latest + + - name: Log in to DigitalOcean Container Registry with short-lived credentials + run: doctl registry login --expiry-seconds 1200 + + - name: Build and push image to DigitalOcean Container Registry + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 0835d97..16511da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,10 +25,15 @@ RUN chown -R node:node /usr/src/app USER node COPY --chown=node:node . . -RUN npm run build +RUN npm run build && npm run build:knexfile FROM base AS prodrunner +COPY docker-entrypoint.sh ./docker-entrypoint.sh +RUN chmod +x ./docker-entrypoint.sh && sed -i 's/\r$//' ./docker-entrypoint.sh && chown node:node ./docker-entrypoint.sh USER node COPY --from=prodbuilder --chown=node:node /usr/src/app/node_modules ./node_modules COPY --from=prodbuilder --chown=node:node /usr/src/app/dist ./dist -CMD node dist/src/server.js +COPY --from=prodbuilder --chown=node:node /usr/src/app/migrations ./migrations +COPY --from=prodbuilder --chown=node:node /usr/src/app/knexfile.js ./knexfile.js +COPY --from=prodbuilder --chown=node:node /usr/src/app/package.json ./package.json +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..9e6d5b1 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,14 @@ +services: + backend: + image: safe-skies-api:test + env_file: .env.test + db: + environment: + - POSTGRES_DB=safeskiesdb + - POSTGRES_PASSWORD=testpassword + healthcheck: + test: ["CMD", "pg_isready"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..bfcff7d --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Running database migrations..." +npm run migrate:up + +echo "Starting server..." +exec node dist/src/server.js diff --git a/migrations/20251028012216_oauth_keys_table.ts b/migrations/20251028012216_oauth_keys_table.ts new file mode 100644 index 0000000..87e9d77 --- /dev/null +++ b/migrations/20251028012216_oauth_keys_table.ts @@ -0,0 +1,20 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("oauth_keys", (table) => { + table.text("key_id").primary(); + table.text("private_key").notNullable(); + table.text("public_key").notNullable(); + table.text("algorithm").notNullable().defaultTo("ES256"); + table.boolean("is_active").notNullable().defaultTo(true); + table.timestamp("created_at").defaultTo(knex.fn.now()); + + table.index("is_active", "idx_oauth_keys_active"); + table.index("created_at", "idx_oauth_keys_created_at"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("oauth_keys"); +} + diff --git a/package.json b/package.json index c11fbcc..89f650d 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,10 @@ "type-check": "tsc --noEmit", "format": "biome format . --write", "prepare": "husky install", - "migrate:create": "knex migrate:make", - "migrate:up": "knex migrate:latest", - "migrate:down": "knex migrate:down" + "migrate:create": "ts-node node_modules/.bin/knex migrate:make", + "migrate:up": "ts-node node_modules/.bin/knex migrate:latest", + "migrate:down": "ts-node node_modules/.bin/knex migrate:down", + "build:knexfile": "tsc knexfile.ts --outDir . --esModuleInterop --module commonjs --target ES2020 --skipLibCheck" }, "keywords": [], "author": "", diff --git a/src/app.ts b/src/app.ts index 52eaccc..f25e74d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -38,8 +38,8 @@ app.use((req, res, next) => { next(); }); +app.use('/auth/admin', adminAuthRouter); app.use('/auth', authRouter); -app.use('/admin/auth', adminAuthRouter); app.use('/oauth', clientMetadataRouter); app.use('/api/feeds', feedsRouter); app.use('/api', profileRouter); diff --git a/src/controllers/admin-auth.controller.ts b/src/controllers/admin-auth.controller.ts index 41cced4..69059fe 100644 --- a/src/controllers/admin-auth.controller.ts +++ b/src/controllers/admin-auth.controller.ts @@ -131,7 +131,7 @@ export const adminStatus = async (req: Request, res: Response): Promise => res.json({ isAuthenticated, adminDid, - loginUrl: isAuthenticated ? null : `${process.env.BASE_URL}/admin/auth/signin?handle=${adminDid}` + loginUrl: isAuthenticated ? null : `${process.env.BASE_URL}/auth/admin/signin?handle=${adminDid}` }); } catch (err) { console.error("Error checking admin status:", err); diff --git a/src/controllers/moderation.controller.ts b/src/controllers/moderation.controller.ts index f02afd7..c2bdb3f 100644 --- a/src/controllers/moderation.controller.ts +++ b/src/controllers/moderation.controller.ts @@ -10,11 +10,161 @@ import { } from "../repos/moderation"; import { customServiceGate, canPerformAction } from "../repos/permissions"; -import { Report, BannedFromTV } from "../lib/types/moderation"; +import { Report, BannedFromTV, OzoneEventType } from "../lib/types/moderation"; import { createModerationLog } from "../repos/logs"; -import { resolveHandleToDid } from "../repos/atproto"; +import { resolveHandleToDid, getAuthenticatedAtprotoAgent } from "../repos/atproto"; import { BLACKSKY_FEED_URI } from "../lib/constants/feeds"; +// Event type to Ozone $type mapping +const EVENT_TYPE_MAP: Record = { + takedown: "tools.ozone.moderation.defs#modEventTakedown", + reverseTakedown: "tools.ozone.moderation.defs#modEventReverseTakedown", + acknowledge: "tools.ozone.moderation.defs#modEventAcknowledge", + escalate: "tools.ozone.moderation.defs#modEventEscalate", + comment: "tools.ozone.moderation.defs#modEventComment", + label: "tools.ozone.moderation.defs#modEventLabel", + tag: "tools.ozone.moderation.defs#modEventTag", +}; + +const VALID_EVENT_TYPES = Object.keys(EVENT_TYPE_MAP) as OzoneEventType[]; + +/** + * Validates event-specific parameters + * Returns error message string if validation fails, null if valid + */ +function validateEventParams( + eventType: OzoneEventType, + params: Record | undefined +): string | null { + switch (eventType) { + case "comment": + if (!params?.comment || typeof params.comment !== "string" || params.comment.trim() === "") { + return "comment event requires a non-empty 'comment' parameter"; + } + break; + + case "label": + if (!params) { + return "label event requires 'createLabelVals' and 'negateLabelVals' parameters"; + } + if (!Array.isArray(params.createLabelVals)) { + return "label event requires 'createLabelVals' to be an array"; + } + if (!Array.isArray(params.negateLabelVals)) { + return "label event requires 'negateLabelVals' to be an array"; + } + if (params.createLabelVals.length === 0 && params.negateLabelVals.length === 0) { + return "label event requires at least one label in 'createLabelVals' or 'negateLabelVals'"; + } + break; + + case "tag": + if (!params) { + return "tag event requires 'add' and 'remove' parameters"; + } + if (!Array.isArray(params.add)) { + return "tag event requires 'add' to be an array"; + } + if (!Array.isArray(params.remove)) { + return "tag event requires 'remove' to be an array"; + } + if (params.add.length === 0 && params.remove.length === 0) { + return "tag event requires at least one tag in 'add' or 'remove'"; + } + break; + + case "takedown": + if (params?.durationInHours !== undefined) { + if (typeof params.durationInHours !== "number" || params.durationInHours < 0) { + return "durationInHours must be a non-negative number"; + } + } + break; + + default: + break; + } + + return null; +} + +/** + * Builds the properly typed event object for Ozone emitEvent + */ +function buildEventObject( + eventType: OzoneEventType, + params: Record | undefined +): { $type: string; [key: string]: unknown } { + const $type = EVENT_TYPE_MAP[eventType]; + + switch (eventType) { + case "takedown": + return { + $type, + comment: params?.comment, + durationInHours: params?.durationInHours, + acknowledgeAccountSubjects: params?.acknowledgeAccountSubjects, + policies: params?.policies, + }; + + case "reverseTakedown": + return { + $type, + comment: params?.comment, + }; + + case "acknowledge": + return { + $type, + comment: params?.comment, + acknowledgeAccountSubjects: params?.acknowledgeAccountSubjects, + }; + + case "escalate": + return { + $type, + comment: params?.comment, + }; + + case "comment": + return { + $type, + comment: params?.comment, + sticky: params?.sticky, + }; + + case "label": + return { + $type, + comment: params?.comment, + createLabelVals: params?.createLabelVals as string[], + negateLabelVals: params?.negateLabelVals as string[], + durationInHours: params?.durationInHours, + }; + + case "tag": + return { + $type, + comment: params?.comment, + add: params?.add as string[], + remove: params?.remove as string[], + }; + + default: + return { $type }; + } +} + +/** + * Extracts the comment field from various event types + */ +function extractCommentFromEvent(event: unknown): string | undefined { + if (event && typeof event === "object" && "comment" in event && typeof (event as { comment: unknown }).comment === "string") { + return (event as { comment: string }).comment; + } + return undefined; +} + export const getReportOptions = async ( req: Request, res: Response, @@ -380,3 +530,347 @@ export const searchBanFromTvBlacksky = async ( res.status(500).json({ error: "Internal server error" }); } }; + +// Type guards for Ozone subject types +const isAccountSubject = (subject: unknown): subject is { did: string } => + typeof subject === 'object' && subject !== null && 'did' in subject && !('uri' in subject); + +const isRecordSubject = (subject: unknown): subject is { uri: string; cid: string } => + typeof subject === 'object' && subject !== null && 'uri' in subject; + +export const getEscalatedUsers = async ( + req: Request, + res: Response, +): Promise => { + try { + const actingUser = req.user; + if (!actingUser) { + res.status(401).json({ error: "Unauthorized: No valid session" }); + return; + } + + // Assumes that if a user can ban Blacksky users that they can interact with Ozone + const hasPermission = await canPerformAction(actingUser.did, "user_ban", BLACKSKY_FEED_URI); + if (!hasPermission) { + res.status(403).json({ error: "Insufficient permissions: Moderator access required" }); + return; + } + + // Get pagination parameters + const { cursor, limit } = req.query; + const limitNum = limit ? Number.parseInt(limit as string, 10) : 50; + + try { + // Get authenticated agent to call Ozone API + const agent = await getAuthenticatedAtprotoAgent(); + + // Fetch all escalated subjects from Ozone (both accounts and records/posts) + const ozoneResponse = await agent.tools.ozone.moderation.queryStatuses({ + reviewState: "tools.ozone.moderation.defs#reviewEscalated", + limit: limitNum, + cursor: cursor as string | undefined, + }); + + // Process subjects and categorize by type + const processedItems: Array<{ + type: 'account' | 'post'; + did: string; + postUri?: string; + postCid?: string; + }> = []; + + for (const status of ozoneResponse.data.subjectStatuses) { + if (isAccountSubject(status.subject)) { + processedItems.push({ + type: 'account', + did: status.subject.did, + }); + } else if (isRecordSubject(status.subject)) { + // Extract DID from post URI: at://did:plc:xxx/app.bsky.feed.post/yyy + const did = status.subject.uri.split('/')[2]; + processedItems.push({ + type: 'post', + did, + postUri: status.subject.uri, + postCid: status.subject.cid, + }); + } + } + + // Collect unique DIDs for profile fetching + const uniqueDids = [...new Set(processedItems.map(item => item.did))]; + + // Batch fetch profiles for all DIDs + const profilesMap = new Map(); + if (uniqueDids.length > 0) { + try { + const profilesResponse = await agent.getProfiles({ actors: uniqueDids }); + for (const profile of profilesResponse.data.profiles) { + profilesMap.set(profile.did, { + handle: profile.handle, + displayName: profile.displayName, + avatar: profile.avatar, + }); + } + } catch (profileError) { + console.warn("Failed to batch fetch profiles:", profileError); + } + } + + // Build response with profile data + const items = processedItems.map(item => ({ + did: item.did, + handle: profilesMap.get(item.did)?.handle, + displayName: profilesMap.get(item.did)?.displayName, + avatar: profilesMap.get(item.did)?.avatar, + type: item.type, + ...(item.postUri && { postUri: item.postUri }), + ...(item.postCid && { postCid: item.postCid }), + })); + + res.status(200).json({ + items, + cursor: ozoneResponse.data.cursor, + hasMore: !!ozoneResponse.data.cursor + }); + + } catch (ozoneError) { + console.error("Error fetching escalated items from Ozone:", ozoneError); + // Graceful fallback - return empty state + res.status(200).json({ + items: [], + cursor: undefined, + hasMore: false + }); + } + } catch (error) { + console.error("Error fetching escalated items:", error); + res.status(500).json({ error: "Internal server error" }); + } +}; + +// Fetch profile moderation data from Ozone +export const getProfileModerationData = async ( + req: Request, + res: Response, +): Promise => { + try { + const actingUser = req.user; + if (!actingUser) { + res.status(401).json({ error: "Unauthorized: No valid session" }); + return; + } + + // Check if user has moderator/admin permissions + const hasPermission = await canPerformAction(actingUser.did, "user_ban", BLACKSKY_FEED_URI); + if (!hasPermission) { + res.status(403).json({ error: "Insufficient permissions: Moderator access required" }); + return; + } + + const { did } = req.params; + if (!did) { + res.status(400).json({ error: "Missing required parameter: did" }); + return; + } + + // Validate DID format + if (!did.startsWith("did:")) { + res.status(400).json({ error: "Invalid DID format. Must start with 'did:'" }); + return; + } + + // Get authenticated agent + const agent = await getAuthenticatedAtprotoAgent(); + + // Fetch subject status from Ozone + let subjectStatus = null; + try { + const repoResponse = await agent.tools.ozone.moderation.getRepo({ did }); + if (repoResponse.data.moderation?.subjectStatus) { + const status = repoResponse.data.moderation.subjectStatus; + subjectStatus = { + reviewState: status.reviewState, + comment: status.comment, + tags: status.tags, + takendown: status.takendown, + appealed: status.appealed, + lastReviewedAt: status.lastReviewedAt, + lastReviewedBy: status.lastReviewedBy, + lastReportedAt: status.lastReportedAt, + muteUntil: status.muteUntil, + suspendUntil: status.suspendUntil, + createdAt: status.createdAt, + updatedAt: status.updatedAt, + }; + } + } catch (repoError) { + // User may not have any moderation status yet - this is not an error + console.warn(`No moderation status found for ${did}:`, repoError); + } + + // Fetch recent moderation events for this subject + let recentEvents: Array<{ + id: number; + eventType: string; + createdBy: string; + createdAt: string; + creatorHandle?: string; + comment?: string; + }> = []; + + try { + const eventsResponse = await agent.tools.ozone.moderation.queryEvents({ + subject: did, + limit: 25, + sortDirection: "desc", + }); + console.log(`${JSON.stringify(eventsResponse)}`); + recentEvents = eventsResponse.data.events.map((event) => ({ + id: event.id, + eventType: event.event.$type || "unknown", + createdBy: event.createdBy, + createdAt: event.createdAt, + creatorHandle: event.creatorHandle, + comment: extractCommentFromEvent(event.event), + })); + } catch (eventsError) { + console.warn(`Failed to fetch events for ${did}:`, eventsError); + } + + // Fetch profile data for context + let profile = undefined; + try { + const profileResponse = await agent.getProfile({ actor: did }); + profile = { + handle: profileResponse.data.handle, + displayName: profileResponse.data.displayName, + avatar: profileResponse.data.avatar, + }; + } catch (profileError) { + console.warn(`Failed to fetch profile for ${did}:`, profileError); + } + + res.status(200).json({ + did, + subjectStatus, + recentEvents, + profile, + }); + } catch (error) { + console.error("Error fetching profile moderation data:", error); + res.status(500).json({ error: "Internal server error" }); + } +}; + +// Emit a moderation event to Ozone +export const emitModerationEvent = async ( + req: Request, + res: Response, +): Promise => { + try { + const actingUser = req.user; + if (!actingUser) { + res.status(401).json({ error: "Unauthorized: No valid session" }); + return; + } + + // Check if user has moderator/admin permissions + const hasPermission = await canPerformAction(actingUser.did, "user_ban", BLACKSKY_FEED_URI); + if (!hasPermission) { + res.status(403).json({ error: "Insufficient permissions: Moderator access required" }); + return; + } + + const { did, eventType, eventParams, subjectUri, subjectCid } = req.body; + + // Validate required fields + if (!did) { + res.status(400).json({ error: "Missing required field: did" }); + return; + } + + if (!eventType) { + res.status(400).json({ error: "Missing required field: eventType" }); + return; + } + + // Validate DID format + if (!did.startsWith("did:")) { + res.status(400).json({ error: "Invalid DID format. Must start with 'did:'" }); + return; + } + + // Validate subjectUri format if provided (should be an AT URI) + if (subjectUri && !subjectUri.startsWith("at://")) { + res.status(400).json({ error: "Invalid subjectUri format. Must be an AT URI starting with 'at://'" }); + return; + } + + // Validate event type + if (!VALID_EVENT_TYPES.includes(eventType)) { + res.status(400).json({ + error: `Invalid eventType. Must be one of: ${VALID_EVENT_TYPES.join(", ")}`, + }); + return; + } + + // Validate event-specific parameters + const validationError = validateEventParams(eventType, eventParams); + if (validationError) { + res.status(400).json({ error: validationError }); + return; + } + + // Build the event object + const eventObject = buildEventObject(eventType, eventParams); + + // Get authenticated agent + const agent = await getAuthenticatedAtprotoAgent(); + + // Build subject based on whether this is a post/record or account action + let subject: { $type: string; did?: string; uri?: string; cid?: string }; + if (subjectUri) { + // Record/post subject - use strongRef + subject = { + $type: "com.atproto.repo.strongRef", + uri: subjectUri, + cid: subjectCid || "", + }; + } else { + // Account subject - use repoRef + subject = { + $type: "com.atproto.admin.defs#repoRef", + did: did, + }; + } + + // Emit the event to Ozone + const response = await agent.tools.ozone.moderation.emitEvent({ + event: eventObject, + subject, + createdBy: actingUser.did, + }); + + const subjectDescription = subjectUri ? `post ${subjectUri}` : `account ${did}`; + res.status(200).json({ + success: true, + eventId: response.data.id, + message: `Successfully emitted ${eventType} event for ${subjectDescription}`, + }); + } catch (error) { + console.error("Error emitting moderation event:", error); + + // Handle specific Ozone errors + if (error instanceof Error) { + if (error.message.includes("SubjectHasAction")) { + res.status(409).json({ + error: "Subject already has an active action of this type", + }); + return; + } + } + + res.status(500).json({ error: "Internal server error" }); + } +}; diff --git a/src/lib/admin-oauth-client.ts b/src/lib/admin-oauth-client.ts index 1289ac4..93651c8 100644 --- a/src/lib/admin-oauth-client.ts +++ b/src/lib/admin-oauth-client.ts @@ -1,13 +1,28 @@ import { NodeOAuthClient } from '@atproto/oauth-client-node'; +import { JoseKey } from '@atproto/jwk-jose'; +import { exportPKCS8, exportSPKI, importJWK } from 'jose'; import { Mutex } from "async-mutex"; import dotenv from "dotenv"; import { BLUE_SKY_ADMIN_CLIENT_META_DATA } from './constants/oauth-config'; import { SessionStore, StateStore } from "../repos/storage"; +import { db } from "../config/db"; +import { encrypt, decrypt } from "./utils/encryption"; dotenv.config(); const mutex = new Mutex(); +export interface OAuthKeyPair { + keyId: string; + privateKey: string; + publicKey: string; + algorithm: string; +} + +export interface JWKS { + keys: Record[]; +} + /** * Request lock to prevent concurrent access to the session store */ @@ -24,6 +39,7 @@ const requestLock = async ( */ class AdminOAuthClientSingleton { private static instance: NodeOAuthClient; + private static joseKey: JoseKey | null = null; private constructor() {} @@ -34,16 +50,22 @@ class AdminOAuthClientSingleton { throw new Error("BASE_URL environment variable is required for admin OAuth client"); } - // Create admin OAuth client with public client configuration + // Get OAuth keys for confidential client authentication + const joseKey = await AdminOAuthClientSingleton.getOrCreateJoseKey(); + + // Create admin OAuth client with confidential client configuration AdminOAuthClientSingleton.instance = new NodeOAuthClient({ // Admin-specific client metadata clientMetadata: BLUE_SKY_ADMIN_CLIENT_META_DATA, - // Use existing encrypted database stores - stateStore: new StateStore(), + // Keyset for private key JWT authentication + keyset: [joseKey], + + // Use admin-prefixed encrypted database stores + stateStore: new StateStore(true), - // Use existing encrypted database session store - sessionStore: new SessionStore(), + // Use admin-prefixed encrypted database session store + sessionStore: new SessionStore(true), // Request lock for concurrent access protection requestLock, @@ -52,6 +74,162 @@ class AdminOAuthClientSingleton { return AdminOAuthClientSingleton.instance; } + + /** + * Initialize OAuth keys - load from database or generate new ones + */ + public static async initializeKeys(): Promise { + try { + await AdminOAuthClientSingleton.getOrCreateJoseKey(); + console.log("OAuth keys initialized successfully"); + } catch (error) { + console.error("Error initializing OAuth keys:", error); + throw new Error("Failed to initialize OAuth keys"); + } + } + + /** + * Get JWKS (JSON Web Key Set) for public key distribution + */ + public static async getJWKS(): Promise { + if (!AdminOAuthClientSingleton.joseKey) { + AdminOAuthClientSingleton.joseKey = await AdminOAuthClientSingleton.getOrCreateJoseKey(); + } + + const publicJwk = AdminOAuthClientSingleton.joseKey.publicJwk; + if (!publicJwk) { + throw new Error("No public key available for JWKS"); + } + return { + keys: [publicJwk as Record] + }; + } + + /** + * Get or create JoseKey (loads from DB or generates new) + */ + private static async getOrCreateJoseKey(): Promise { + if (AdminOAuthClientSingleton.joseKey) { + return AdminOAuthClientSingleton.joseKey; + } + + try { + // Try to load existing keys from database + const existingKeyPair = await AdminOAuthClientSingleton.loadKeyPairFromDatabase(); + + if (existingKeyPair) { + AdminOAuthClientSingleton.joseKey = await JoseKey.fromImportable( + existingKeyPair.privateKey, + existingKeyPair.keyId + ); + console.log(`OAuth keys loaded from database (key ID: ${existingKeyPair.keyId})`); + } else { + // Generate new keys if none exist + const keyId = `oauth-key-${Date.now()}`; + AdminOAuthClientSingleton.joseKey = await JoseKey.generate(['ES256'], keyId); + const keyPair = await AdminOAuthClientSingleton.joseKeyToKeyPair( + AdminOAuthClientSingleton.joseKey + ); + await AdminOAuthClientSingleton.saveKeyPairToDatabase(keyPair); + console.log(`New OAuth keys generated and saved (key ID: ${keyPair.keyId})`); + } + + return AdminOAuthClientSingleton.joseKey; + } catch (error) { + console.error("Error loading/generating OAuth keys:", error); + throw new Error("Failed to initialize OAuth keys"); + } + } + + /** + * Convert JoseKey to OAuthKeyPair format for database storage + */ + private static async joseKeyToKeyPair(joseKey: JoseKey): Promise { + const keyId = joseKey.kid; + if (!keyId) { + throw new Error("JoseKey must have a kid for storage"); + } + + // Get both private and public JWK representations + const privateJwk = joseKey.privateJwk; + const publicJwk = joseKey.publicJwk; + + if (!privateJwk || !publicJwk) { + throw new Error("No private or public key available for storage"); + } + + // Import the JWKs to get KeyLike objects for PEM export + const privateKeyObj = await importJWK(privateJwk, 'ES256'); + const publicKeyObj = await importJWK(publicJwk, 'ES256'); + + // Ensure we have KeyLike objects (not Uint8Array) + if (privateKeyObj instanceof Uint8Array || publicKeyObj instanceof Uint8Array) { + throw new Error("Cannot export symmetric key as PEM"); + } + + // Export to PEM formats + const privateKeyPem = await exportPKCS8(privateKeyObj); + const publicKeyPem = await exportSPKI(publicKeyObj); + + return { + keyId, + privateKey: privateKeyPem, + publicKey: publicKeyPem, + algorithm: 'ES256' + }; + } + + /** + * Load keys from database + */ + private static async loadKeyPairFromDatabase(): Promise { + try { + const row = await db("oauth_keys") + .select("key_id", "private_key", "public_key", "algorithm") + .where({ is_active: true }) + .orderBy("created_at", "desc") + .first(); + + if (!row) { + return null; + } + + // Decrypt the keys + const decryptedPrivateKey = decrypt(JSON.parse(row.private_key)); + const decryptedPublicKey = decrypt(JSON.parse(row.public_key)); + + return { + keyId: row.key_id, + privateKey: decryptedPrivateKey, + publicKey: decryptedPublicKey, + algorithm: row.algorithm + }; + } catch (error) { + console.warn("Failed to load OAuth keys from database, will generate new ones:", error); + return null; + } + } + + /** + * Save keys to database + */ + private static async saveKeyPairToDatabase(keyPair: OAuthKeyPair): Promise { + // Encrypt the keys + const encryptedPrivateKey = encrypt(keyPair.privateKey); + const encryptedPublicKey = encrypt(keyPair.publicKey); + + // Deactivate existing keys + await db("oauth_keys").update({ is_active: false }); + + // Insert new key + await db("oauth_keys").insert({ + key_id: keyPair.keyId, + private_key: JSON.stringify(encryptedPrivateKey), + public_key: JSON.stringify(encryptedPublicKey), + algorithm: keyPair.algorithm, + is_active: true + }); + } } export { AdminOAuthClientSingleton }; \ No newline at end of file diff --git a/src/lib/constants/oauth-config.ts b/src/lib/constants/oauth-config.ts index bbe8151..65cb8aa 100644 --- a/src/lib/constants/oauth-config.ts +++ b/src/lib/constants/oauth-config.ts @@ -28,14 +28,16 @@ export const BLUE_SKY_CLIENT_META_DATA: OAuthClientMetadataInput = { export const BLUE_SKY_ADMIN_CLIENT_META_DATA: OAuthClientMetadataInput = { // Must be a URL that will expose admin client metadata - client_id: `${baseUrl}/admin/auth/client-metadata.json`, + client_id: `${baseUrl}/auth/admin/client-metadata.json`, client_name: 'Safe Skies Admin', client_uri: baseUrl, - redirect_uris: [`${baseUrl}/admin/auth/callback`], + redirect_uris: [`${baseUrl}/auth/admin/callback`], grant_types: ['authorization_code', 'refresh_token'], scope: 'atproto transition:generic', response_types: ['code'], application_type: 'web', - token_endpoint_auth_method: 'none', + token_endpoint_auth_method: 'private_key_jwt', + token_endpoint_auth_signing_alg: 'ES256', + jwks_uri: `${baseUrl}/auth/admin/jwks.json`, dpop_bound_access_tokens: true, } \ No newline at end of file diff --git a/src/lib/types/moderation.ts b/src/lib/types/moderation.ts index 537e0e0..c883f3c 100644 --- a/src/lib/types/moderation.ts +++ b/src/lib/types/moderation.ts @@ -67,3 +67,13 @@ export interface MuteFilters { limit?: number; offset?: number; } + +// Ozone moderation event types +export type OzoneEventType = + | "takedown" + | "reverseTakedown" + | "acknowledge" + | "escalate" + | "comment" + | "label" + | "tag"; diff --git a/src/repos/atproto.ts b/src/repos/atproto.ts index 83e79e9..71e9a3a 100644 --- a/src/repos/atproto.ts +++ b/src/repos/atproto.ts @@ -40,15 +40,15 @@ class AtpAgentSingleton { } catch (error) { if (error instanceof Error) { if (error.message.includes('not found') || error.message.includes('No session')) { - throw new Error(`Admin OAuth session not found. Please authenticate at ${process.env.BASE_URL}/admin/auth/signin`); + throw new Error(`Admin OAuth session not found. Please authenticate at ${process.env.BASE_URL}/auth/admin/signin`); } if (error.message.includes('expired') || error.message.includes('refresh')) { - throw new Error(`Admin session expired and refresh failed. Please re-authenticate at ${process.env.BASE_URL}/admin/auth/signin`); + throw new Error(`Admin session expired and refresh failed. Please re-authenticate at ${process.env.BASE_URL}/auth/admin/signin`); } } console.error("Error getting authenticated admin agent:", error); - throw new Error(`Failed to get authenticated admin agent. Please re-authenticate at ${process.env.BASE_URL}/admin/auth/signin`); + throw new Error(`Failed to get authenticated admin agent. Please re-authenticate at ${process.env.BASE_URL}/auth/admin/signin`); } } } diff --git a/src/repos/storage.ts b/src/repos/storage.ts index 94f1f18..e399aad 100644 --- a/src/repos/storage.ts +++ b/src/repos/storage.ts @@ -5,14 +5,22 @@ import { decrypt, encrypt } from "../lib/utils/encryption"; * StateStore manages short-lived OAuth state entries in the 'auth_states' table. */ export class StateStore { + private prefix: string; + + constructor(isAdmin: boolean = false) { + // Store the key for admin mute functionality separately + this.prefix = isAdmin ? "admin::" : ""; + } + /** * Retrieves and decrypts a stored state by key. */ async get(key: string) { try { + const prefixedKey = this.prefix + key; const row = await db("auth_states") .select("state") - .where({ key }) + .where({ key: prefixedKey }) .first(); if (!row) { @@ -50,12 +58,13 @@ export class StateStore { */ async set(key: string, value: object) { try { + const prefixedKey = this.prefix + key; const valueString = JSON.stringify(value); const { iv, encrypted } = encrypt(valueString); const encryptedState = JSON.stringify({ iv, encrypted }); await db("auth_states") - .insert({ key, state: encryptedState }) + .insert({ key: prefixedKey, state: encryptedState }) .onConflict("key") .merge({ state: encryptedState }); } catch (error) { @@ -69,7 +78,8 @@ export class StateStore { */ async del(key: string) { try { - await db("auth_states").where({ key }).del(); + const prefixedKey = this.prefix + key; + await db("auth_states").where({ key: prefixedKey }).del(); } catch (error) { console.error("StateStore.del error:", error); throw error; @@ -81,14 +91,21 @@ export class StateStore { * SessionStore manages longer-lived user sessions in the 'auth_sessions' table. */ export class SessionStore { + private prefix: string; + + constructor(isAdmin: boolean = false) { + this.prefix = isAdmin ? "admin::" : ""; + } + /** * Retrieves and decrypts a stored session by key. */ async get(key: string) { try { + const prefixedKey = this.prefix + key; const row = await db("auth_sessions") .select("session") - .where({ key }) + .where({ key: prefixedKey }) .first(); if (!row) { return undefined; @@ -127,12 +144,13 @@ export class SessionStore { */ async set(key: string, value: object) { try { + const prefixedKey = this.prefix + key; const valueString = JSON.stringify(value); const { iv, encrypted } = encrypt(valueString); const encryptedSession = JSON.stringify({ iv, encrypted }); await db("auth_sessions") - .insert({ key, session: encryptedSession }) + .insert({ key: prefixedKey, session: encryptedSession }) .onConflict("key") .merge({ session: encryptedSession }); } catch (error) { @@ -146,7 +164,8 @@ export class SessionStore { */ async del(key: string) { try { - await db("auth_sessions").where({ key }).del(); + const prefixedKey = this.prefix + key; + await db("auth_sessions").where({ key: prefixedKey }).del(); } catch (error) { console.error("SessionStore.del error:", error); throw error; diff --git a/src/routes/admin-auth.ts b/src/routes/admin-auth.ts index 46fcc39..f01ed7a 100644 --- a/src/routes/admin-auth.ts +++ b/src/routes/admin-auth.ts @@ -6,6 +6,7 @@ import { adminStatus } from "../controllers/admin-auth.controller"; import { BLUE_SKY_ADMIN_CLIENT_META_DATA } from "../lib/constants/oauth-config"; +import { AdminOAuthClientSingleton } from "../lib/admin-oauth-client"; const router = Router(); @@ -36,5 +37,22 @@ router.get("/client-metadata.json", async (req: Request, res: Response): Promise } }); +router.get("/jwks.json", async (_req: Request, res: Response): Promise => { + try { + // Set proper headers for JWKS response + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour + + const jwks = await AdminOAuthClientSingleton.getJWKS(); + res.json(jwks); + } catch (error) { + console.error("Error serving JWKS:", error); + res.status(500).json({ + error: "internal_server_error", + error_description: "Failed to load OAuth public keys" + }); + } +}); + export default router; \ No newline at end of file diff --git a/src/routes/moderation.ts b/src/routes/moderation.ts index dd53cce..515e791 100644 --- a/src/routes/moderation.ts +++ b/src/routes/moderation.ts @@ -6,6 +6,9 @@ import { banFromTvBlacksky, unbanFromTvBlacksky, searchBanFromTvBlacksky, + getEscalatedUsers, + getProfileModerationData, + emitModerationEvent, } from "../controllers/moderation.controller"; import { muteUserHandler, @@ -30,5 +33,9 @@ router.delete("/user/mute", authenticateJWT, unmuteUserHandler); router.get("/user/mute/check", authenticateJWT, checkMutedHandler); router.get("/user/mute", authenticateJWT, listMutedUsersHandler); +// Ozone integration endpoints +router.get("/escalated-users", authenticateJWT, getEscalatedUsers); +router.post("/emit-event", authenticateJWT, emitModerationEvent); +router.get("/profile/:did", authenticateJWT, getProfileModerationData); export default router; diff --git a/src/routes/oauth-jwks.ts b/src/routes/oauth-jwks.ts new file mode 100644 index 0000000..35e53b5 --- /dev/null +++ b/src/routes/oauth-jwks.ts @@ -0,0 +1,27 @@ +import { Router, Request, Response } from "express"; +import { AdminOAuthClientSingleton } from "../lib/admin-oauth-client"; + +const router = Router(); + +/** + * JWKS (JSON Web Key Set) endpoint + * Returns public keys for OAuth client authentication verification + */ +router.get("/jwks.json", async (_req: Request, res: Response): Promise => { + try { + // Set proper headers for JWKS response + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour + + const jwks = await AdminOAuthClientSingleton.getJWKS(); + res.json(jwks); + } catch (error) { + console.error("Error serving JWKS:", error); + res.status(500).json({ + error: "internal_server_error", + error_description: "Failed to load OAuth public keys" + }); + } +}); + +export default router; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 3014ad9..171e73a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,13 +1,15 @@ import app from "./app"; import { startReconciliationService } from "./services/reconciliation"; +import { AdminOAuthClientSingleton } from "./lib/admin-oauth-client"; const PORT = process.env.PORT || 5000; -app.listen(PORT, () => { +app.listen(PORT, async () => { if (process.env.NODE_ENV === "development") { console.log(`Server running on port ${PORT}`); } + await AdminOAuthClientSingleton.initializeKeys(); // Start the mute list reconciliation service startReconciliationService(); }); diff --git a/src/services/reconciliation.ts b/src/services/reconciliation.ts index 8354197..85b8a48 100644 --- a/src/services/reconciliation.ts +++ b/src/services/reconciliation.ts @@ -73,7 +73,7 @@ export async function runReconciliation(): Promise { // Check if this is an OAuth authentication error if (error instanceof Error && error.message.includes("OAuth session not found")) { console.error(`Mute list reconciliation failed after ${duration}ms - Admin OAuth authentication required:`, error.message); - console.error(`Please authenticate admin at: ${process.env.BASE_URL}/admin/auth/signin?handle=${process.env.MUTE_LIST_ADMIN_DID}`); + console.error(`Please authenticate admin at: ${process.env.BASE_URL}/auth/admin/signin?handle=${process.env.MUTE_LIST_ADMIN_DID}`); } else if (error instanceof Error && error.message.includes("session expired")) { console.error(`Mute list reconciliation failed after ${duration}ms - Admin session expired:`, error.message); } else {