Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import type * as lib_apiKeyHelpers from "../lib/apiKeyHelpers.js";
import type * as lib_auth from "../lib/auth.js";
import type * as lib_authUser from "../lib/authUser.js";
import type * as lib_bucket from "../lib/bucket.js";
import type * as lib_didLogAuth from "../lib/didLogAuth.js";
import type * as lib_httpResponses from "../lib/httpResponses.js";
import type * as lib_itemCategories from "../lib/itemCategories.js";
import type * as lib_jwt from "../lib/jwt.js";
Expand Down Expand Up @@ -123,6 +124,7 @@ declare const fullApi: ApiFromModules<{
"lib/auth": typeof lib_auth;
"lib/authUser": typeof lib_authUser;
"lib/bucket": typeof lib_bucket;
"lib/didLogAuth": typeof lib_didLogAuth;
"lib/httpResponses": typeof lib_httpResponses;
"lib/itemCategories": typeof lib_itemCategories;
"lib/jwt": typeof lib_jwt;
Expand Down
8 changes: 6 additions & 2 deletions convex/didLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
*/

import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { internalMutation, query } from "./_generated/server";

/**
* Store or update a user's DID log.
*
* Internal: it writes whatever `userDid` it is handed, so the caller owns the
* ownership check. As a public mutation this was directly callable by anyone
* with the deployment URL. Go through didLogsHttp.storeDidLog.
*/
export const upsertDidLog = mutation({
export const upsertDidLog = internalMutation({
args: {
userDid: v.string(),
path: v.string(),
Expand Down
35 changes: 24 additions & 11 deletions convex/didLogsHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
*/

import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
import { api, internal } from "./_generated/api";
import { requireAuth, AuthError } from "./lib/auth";
import { assertDidLogOwnership, DidLogOwnershipError } from "./lib/didLogAuth";

function getCorsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get("Origin") || "*";
Expand All @@ -19,20 +21,17 @@ function getCorsHeaders(request: Request): Record<string, string> {
}

/**
* Store/update a DID log. Requires JWT auth.
* Store/update a DID log for the authenticated caller.
*
* The body's `userDid`/`path` are checked against the ones derivable from the
* caller's token, never trusted — this endpoint decides what the world resolves
* for an identity.
*/
export const storeDidLog = httpAction(async (ctx, request) => {
const corsHeaders = getCorsHeaders(request);

try {
// Verify auth via Authorization header
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
const auth = await requireAuth(request);

const body = await request.json();
const { userDid, path, log } = body as { userDid: string; path: string; log: string };
Expand All @@ -44,13 +43,27 @@ export const storeDidLog = httpAction(async (ctx, request) => {
});
}

await ctx.runMutation(api.didLogs.upsertDidLog, { userDid, path, log });
assertDidLogOwnership({ subOrgId: auth.turnkeySubOrgId, userDid, path });

await ctx.runMutation(internal.didLogs.upsertDidLog, { userDid, path, log });

return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
} catch (error) {
if (error instanceof AuthError) {
return new Response(JSON.stringify({ error: error.message }), {
status: 401,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
if (error instanceof DidLogOwnershipError) {
return new Response(JSON.stringify({ error: error.message }), {
status: 403,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
console.error("[didLogsHttp] Store error:", error);
return new Response(JSON.stringify({ error: "Failed to store DID log" }), {
status: 500,
Expand Down
61 changes: 61 additions & 0 deletions convex/lib/didLogAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Binds a DID log write to the caller's own identity.
*
* A did:webvh log is what the world resolves to learn a user's keys, so the
* write path must not take the target identity on trust. It doesn't have to:
* the client derives its serving path as `user-<first 16 of subOrgId>`
* (toUserSlug in src/lib/webvh.ts) and mints did:webvh:<scid>:<domain>:<path>,
* so both are recomputable from the JWT alone.
*
* Duplicated rather than imported because Convex modules cannot reach into
* src/ — keep in step with toUserSlug; the test asserts they agree.
*/

export class DidLogOwnershipError extends Error {
constructor(message: string) {
super(message);
this.name = "DidLogOwnershipError";
}
}

/** The only path this sub-org may serve a DID log at. */
export function didLogPathForSubOrg(subOrgId: string): string {
return `user-${subOrgId.slice(0, 16)}`;
}

/**
* Throws unless `userDid` and `path` are the ones this sub-org owns.
*
* Matches the DID's trailing path as a whole segment (`:<slug>`) rather than
* parsing by index, so a dev domain carrying a port (`localhost%3A5173`) still
* binds and `evil<slug>` still doesn't.
*/
export function assertDidLogOwnership(params: {
subOrgId: string;
userDid: string;
path: string;
}): void {
const { subOrgId, userDid, path } = params;

if (!subOrgId) {
throw new DidLogOwnershipError("Authenticated caller has no sub-organization ID");
}

const expectedPath = didLogPathForSubOrg(subOrgId);

if (path !== expectedPath) {
throw new DidLogOwnershipError(
`Path "${path}" is not this account's DID log path`
);
}

if (!userDid.startsWith("did:webvh:")) {
throw new DidLogOwnershipError(`Expected a did:webvh, got "${userDid}"`);
}

if (!userDid.endsWith(`:${expectedPath}`)) {
throw new DidLogOwnershipError(
`DID "${userDid}" does not belong to this account`
);
}
}
7 changes: 6 additions & 1 deletion convex/lib/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@ export async function verifyAuthToken(token: string): Promise<AuthTokenPayload>
// Encode secret as Uint8Array for jose
const secret = new TextEncoder().encode(jwtSecret);

// Verify the token
// Pin issuer/audience/exp to what signJwtToken mints (authInternal.ts).
// Without them any other HS256 token sharing JWT_SECRET — different
// service, different audience, or no expiry at all — passes as a session.
const { payload } = await jose.jwtVerify(token, secret, {
algorithms: ["HS256"],
issuer: "originals-auth",
audience: "originals-api",
requiredClaims: ["exp"],
});

const jwtPayload = payload as unknown as JWTPayload;
Expand Down
11 changes: 11 additions & 0 deletions convex/userHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
unauthorizedResponseWithCors,
} from "./lib/auth";
import { jsonResponse, errorResponse } from "./lib/httpResponses";
import { assertDidLogOwnership, DidLogOwnershipError } from "./lib/didLogAuth";

/** The domain encoded in a did:webvh, percent-decoded. Null if not a did:webvh. */
function didWebvhDomain(did: string): string | null {
Expand Down Expand Up @@ -139,6 +140,13 @@ export const remintUserDID = httpAction(async (ctx, request) => {
});
}

// Checked before applyRemint: storeDidLog patches the didLogs row matching
// `path`, so an unchecked body could point any other account's serving path
// at this caller's log. Rejecting afterwards would leave rows already moved.
if (didLog && path) {
assertDidLogOwnership({ subOrgId: auth.turnkeySubOrgId, userDid: newDid, path });
}

const { rewritten } = await ctx.runMutation(
internal.migrations.remintUserDidDb.applyRemint,
{ userId: user._id, oldDid: user.did, newDid }
Expand All @@ -160,6 +168,9 @@ export const remintUserDID = httpAction(async (ctx, request) => {
if (err instanceof AuthError) {
return unauthorizedResponseWithCors(request, err.message);
}
if (err instanceof DidLogOwnershipError) {
return errorResponse(request, err.message, 403);
}
console.error("[userHttp] Re-mint error:", err);
return errorResponse(
request,
Expand Down
Loading
Loading