mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Harden the remote MCP endpoint and the in-repo OAuth bridge against the adversarial review of the milestone branch. The two ship-blockers made the "read-only, revocable" promise false; the rest tightened the surface. Audience binding at the resource server: a token whose only grant is the new `health:read` scope is the MCP read credential. The REST edge now accepts it on safe (read) methods and refuses it on writes/deletes with 403, so a connector token can read over `/mcp` and GET routes but can never POST/DELETE the record despite a "read-only" consent. The `/mcp` resolver is unaffected. Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a persistent connection row. Revoking a connection stops every future refresh and kills its access tokens; a replayed (already-rotated) refresh is treated as theft and revokes the whole connection. Connections surface in settings as the unit a user can disconnect. The old access token is revoked on rotation. Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is configured and mark the discovery documents non-cacheable; honour the global API kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and registration body reads while streaming instead of after; show the verified client origin on consent; validate CIMD redirect-URI schemes; assert same-origin on the consent POST; and add a daily prune for expired connector tokens. New migration 0195 adds the connection anchor and links access tokens to it.
92 lines
2.8 KiB
TypeScript
92 lines
2.8 KiB
TypeScript
/**
|
|
* Helper for issuing an `ApiToken` for a user — currently used by the
|
|
* native-client auto-login flow (login + passkey login-verify) so the iOS
|
|
* app can talk to bearer-protected routes without juggling the session
|
|
* cookie. The raw `hlk_<hex>` value is returned exactly once.
|
|
*/
|
|
import { randomBytes } from "node:crypto";
|
|
import { prisma } from "@/lib/db";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
|
|
export interface IssuedToken {
|
|
token: string;
|
|
expiresAt: Date;
|
|
tokenId: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface IssueTokenOptions {
|
|
userId: string;
|
|
/** Token row name (also surfaced to the user under /api/tokens). */
|
|
name: string;
|
|
/** Default permissions. `["*"]` = unrestricted (matches cookie scope). */
|
|
permissions?: string[];
|
|
/** Lifetime in days. */
|
|
expiresInDays?: number;
|
|
/**
|
|
* Lifetime in minutes. When set it WINS over `expiresInDays` — used for the
|
|
* short-lived OAuth access tokens the MCP bridge mints (the refresh token
|
|
* carries continuity). Ignored when `expiresInDays` alone is supplied.
|
|
*/
|
|
expiresInMinutes?: number;
|
|
/**
|
|
* Links this access token to an MCP OAuth connection (H2). Set only by the
|
|
* `/api/mcp/oauth` bridge so a connection revoke can kill every access row it
|
|
* issued and the connector token list can exclude the transient access rows.
|
|
*/
|
|
mcpConnectionId?: string;
|
|
}
|
|
|
|
/**
|
|
* Generate a new `hlk_<64 hex>` token, store its hash, and return the raw
|
|
* value to the caller. The plaintext token is never persisted and must
|
|
* never be logged.
|
|
*/
|
|
export async function issueApiToken(
|
|
opts: IssueTokenOptions,
|
|
): Promise<IssuedToken> {
|
|
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
|
|
const tokenHashValue = hashToken(rawToken);
|
|
const expiresAt =
|
|
opts.expiresInMinutes !== undefined
|
|
? new Date(Date.now() + opts.expiresInMinutes * 60 * 1000)
|
|
: new Date(Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000);
|
|
|
|
const created = await prisma.apiToken.create({
|
|
data: {
|
|
userId: opts.userId,
|
|
name: opts.name,
|
|
tokenHash: tokenHashValue,
|
|
permissions: opts.permissions ?? ["*"],
|
|
expiresAt,
|
|
...(opts.mcpConnectionId
|
|
? { mcpConnectionId: opts.mcpConnectionId }
|
|
: {}),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
return {
|
|
token: rawToken,
|
|
expiresAt,
|
|
tokenId: created.id,
|
|
name: opts.name,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Detect whether the request is from a native client and should receive a
|
|
* bearer token in the login response. Two opt-in signals:
|
|
* - `X-Client-Type: native`
|
|
* - `User-Agent: HealthLog-iOS/...`
|
|
*/
|
|
export function isNativeClientRequest(headers: Headers): boolean {
|
|
const xClient = headers.get("x-client-type");
|
|
if (xClient && xClient.toLowerCase() === "native") return true;
|
|
|
|
const ua = headers.get("user-agent") ?? "";
|
|
if (ua.startsWith("HealthLog-iOS")) return true;
|
|
|
|
return false;
|
|
}
|