Three Low/Info hardening items from the 2026-06-10 security review.
OAuth/credential log redaction:
- oauth-flow.server.ts logged the raw exception on code-exchange
failure; a provider error can embed the auth code / token response,
which would land in logs + Sentry. Log only e.message now.
- manager.markNeedsRelink bounded the provider-supplied reason string
to 200 chars before logging.
Magic-link account enumeration:
- createMagicToken now returns null (instead of throwing "No account
found for this email") when no account matches; the login route
always responds { step: "magic-link-sent" }, minting a token and
sending mail only for a real account. The public login form can no
longer be used to probe which emails are registered. Registration's
email/username "already in use/taken" messages are intentionally
unchanged — standard signup UX, and the passkey ceremony can't be
made to fake-succeed.
Federation remote-document size cap:
- assertRemoteDocSize rejects an actor/outbox document over 4 MB once
serialized, applied in the ingest fetchJson seam. Fedify owns the
transfer (with its own SSRF + redirect limits) and our poll uses the
authenticated loader for secure-mode instances, so this is a
downstream guard on iteration/persistence, complementing the existing
per-poll item cap.
Tests: assertRemoteDocSize unit cases; a gated (FEDERATION_INTEGRATION=1)
integration test asserting an unsigned POST to /users/:u/inbox is
rejected with 401 — a regression guard for Fedify's signature
verification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
256 lines
8.1 KiB
TypeScript
256 lines
8.1 KiB
TypeScript
// ConnectedServiceManager — owns credential lifecycle for every provider.
|
|
//
|
|
// Importers, route pushers, and webhook handlers obtain credentials
|
|
// EXCLUSIVELY through withFreshCredentials. They never read the
|
|
// `credentials` JSONB blob directly.
|
|
//
|
|
// See docs/adr/0001-0003 and CONTEXT.md (Connected Services).
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { connectedServices } from "@trails-cool/db/schema/journal";
|
|
import { getDb } from "../db.ts";
|
|
import {
|
|
ConnectionNotActiveError,
|
|
NeedsRelinkError,
|
|
type ConnectedService,
|
|
type CredentialKind,
|
|
type Credentials,
|
|
type ConnectionStatus,
|
|
} from "./types.ts";
|
|
import { getManifest, type CapabilityContext } from "./registry.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Row mapping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Row = typeof connectedServices.$inferSelect;
|
|
|
|
function toModel(row: Row): ConnectedService {
|
|
return {
|
|
id: row.id,
|
|
userId: row.userId,
|
|
provider: row.provider,
|
|
credentialKind: row.credentialKind as CredentialKind,
|
|
credentials: row.credentials as Credentials,
|
|
status: row.status as ConnectionStatus,
|
|
providerUserId: row.providerUserId,
|
|
grantedScopes: row.grantedScopes,
|
|
createdAt: row.createdAt,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lookups
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function getService(
|
|
userId: string,
|
|
provider: string,
|
|
): Promise<ConnectedService | null> {
|
|
const db = getDb();
|
|
const [row] = await db
|
|
.select()
|
|
.from(connectedServices)
|
|
.where(
|
|
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
|
|
);
|
|
return row ? toModel(row) : null;
|
|
}
|
|
|
|
export async function getServiceById(
|
|
serviceId: string,
|
|
): Promise<ConnectedService | null> {
|
|
const db = getDb();
|
|
const [row] = await db
|
|
.select()
|
|
.from(connectedServices)
|
|
.where(eq(connectedServices.id, serviceId));
|
|
return row ? toModel(row) : null;
|
|
}
|
|
|
|
export async function getServiceByProviderUser(
|
|
provider: string,
|
|
providerUserId: string,
|
|
): Promise<ConnectedService | null> {
|
|
const db = getDb();
|
|
const [row] = await db
|
|
.select()
|
|
.from(connectedServices)
|
|
.where(
|
|
and(
|
|
eq(connectedServices.provider, provider),
|
|
eq(connectedServices.providerUserId, providerUserId),
|
|
),
|
|
);
|
|
return row ? toModel(row) : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mutation: link / unlink / status
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface LinkInput {
|
|
userId: string;
|
|
provider: string;
|
|
credentialKind: CredentialKind;
|
|
credentials: Credentials;
|
|
providerUserId?: string | null;
|
|
grantedScopes?: string[];
|
|
}
|
|
|
|
// Upsert the (user_id, provider) row with fresh credentials. The DB-level
|
|
// unique constraint on (user_id, provider) guarantees at most one row per
|
|
// pair; we delete-then-insert to keep the row id stable per link, which
|
|
// also resets `created_at`.
|
|
export async function link(input: LinkInput): Promise<ConnectedService> {
|
|
const db = getDb();
|
|
await db
|
|
.delete(connectedServices)
|
|
.where(
|
|
and(
|
|
eq(connectedServices.userId, input.userId),
|
|
eq(connectedServices.provider, input.provider),
|
|
),
|
|
);
|
|
const id = randomUUID();
|
|
await db.insert(connectedServices).values({
|
|
id,
|
|
userId: input.userId,
|
|
provider: input.provider,
|
|
credentialKind: input.credentialKind,
|
|
credentials: input.credentials,
|
|
status: "active",
|
|
providerUserId: input.providerUserId ?? null,
|
|
grantedScopes: input.grantedScopes ?? [],
|
|
});
|
|
const row = await getServiceById(id);
|
|
if (!row) throw new Error("Failed to load just-linked service");
|
|
return row;
|
|
}
|
|
|
|
export async function unlink(serviceId: string): Promise<void> {
|
|
const db = getDb();
|
|
// Best-effort revoke at the provider before deleting locally.
|
|
const service = await getServiceById(serviceId);
|
|
if (service) {
|
|
const manifest = getManifest(service.provider);
|
|
if (manifest?.credentialAdapter.revoke && manifest.oauthConfig) {
|
|
await manifest.credentialAdapter
|
|
.revoke(service.credentials, manifest.oauthConfig)
|
|
.catch(() => {
|
|
// Swallow — local delete proceeds regardless.
|
|
});
|
|
}
|
|
}
|
|
await db.delete(connectedServices).where(eq(connectedServices.id, serviceId));
|
|
}
|
|
|
|
export async function unlinkByUserProvider(
|
|
userId: string,
|
|
provider: string,
|
|
): Promise<void> {
|
|
const service = await getService(userId, provider);
|
|
if (!service) return;
|
|
await unlink(service.id);
|
|
}
|
|
|
|
export async function markNeedsRelink(
|
|
serviceId: string,
|
|
reason: string,
|
|
): Promise<void> {
|
|
const db = getDb();
|
|
await db
|
|
.update(connectedServices)
|
|
.set({ status: "needs_relink" })
|
|
.where(eq(connectedServices.id, serviceId));
|
|
// Reason is logged but not persisted yet — add a column if/when we surface it in UI.
|
|
// Bounded: `reason` can carry provider-side error text, so cap its length
|
|
// to avoid dumping a large/sensitive blob into logs.
|
|
const safeReason = reason.length > 200 ? `${reason.slice(0, 200)}…` : reason;
|
|
console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${safeReason}`);
|
|
}
|
|
|
|
// Provider-side revocation (e.g. a Garmin deregistration notification):
|
|
// keep the row for audit, flip to 'revoked' so every subsequent
|
|
// withFreshCredentials short-circuits and the UI shows a re-connect
|
|
// prompt. Imported activities are untouched.
|
|
export async function markRevoked(serviceId: string): Promise<void> {
|
|
const db = getDb();
|
|
await db
|
|
.update(connectedServices)
|
|
.set({ status: "revoked" })
|
|
.where(eq(connectedServices.id, serviceId));
|
|
}
|
|
|
|
export async function updateGrantedScopes(
|
|
serviceId: string,
|
|
grantedScopes: string[],
|
|
): Promise<void> {
|
|
const db = getDb();
|
|
await db
|
|
.update(connectedServices)
|
|
.set({ grantedScopes })
|
|
.where(eq(connectedServices.id, serviceId));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// withFreshCredentials — the chokepoint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Loads the connection, refreshes credentials if expired, calls fn with the
|
|
// fresh credentials. On a NeedsRelinkError from the adapter, flips the
|
|
// connection to needs_relink and re-throws so the caller can surface a
|
|
// re-link prompt.
|
|
//
|
|
// Capability adapters use this exclusively — they never read the
|
|
// credentials JSONB directly.
|
|
export async function withFreshCredentials<T>(
|
|
serviceId: string,
|
|
fn: (credentials: unknown) => Promise<T>,
|
|
): Promise<T> {
|
|
const service = await getServiceById(serviceId);
|
|
if (!service) throw new Error(`Connected service ${serviceId} not found`);
|
|
if (service.status !== "active") {
|
|
throw new ConnectionNotActiveError(service.status);
|
|
}
|
|
|
|
const manifest = getManifest(service.provider);
|
|
if (!manifest) {
|
|
throw new Error(`No manifest registered for provider ${service.provider}`);
|
|
}
|
|
const adapter = manifest.credentialAdapter;
|
|
|
|
let creds = service.credentials;
|
|
if (adapter.isExpired(creds)) {
|
|
if (!manifest.oauthConfig && service.credentialKind === "oauth") {
|
|
throw new Error(
|
|
`Provider ${service.provider} has no oauthConfig; cannot refresh`,
|
|
);
|
|
}
|
|
try {
|
|
creds = await adapter.refresh(creds, manifest.oauthConfig!);
|
|
const db = getDb();
|
|
await db
|
|
.update(connectedServices)
|
|
.set({ credentials: creds })
|
|
.where(eq(connectedServices.id, serviceId));
|
|
} catch (err) {
|
|
if (err instanceof NeedsRelinkError) {
|
|
await markNeedsRelink(serviceId, err.reason);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
return fn(creds);
|
|
}
|
|
|
|
// Build a CapabilityContext bound to a service id. Capability adapters
|
|
// receive this from the route handler / job that invoked them.
|
|
export function capabilityContextFor(serviceId: string): CapabilityContext {
|
|
return {
|
|
serviceId,
|
|
withFreshCredentials: (fn) => withFreshCredentials(serviceId, fn),
|
|
};
|
|
}
|