Skip to content
Open
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: 1 addition & 1 deletion server/core/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export { isSafeProjectIdentifier } from "@scratchwork/shared/site/identifiers";
* ".well-known") is unclaimable without an entry here. */
const RESERVED_ROUTE_SLUGS: ReadonlySet<string> = new Set([
// Server-owned routes.
"api", "auth", "health", "favicon.ico", "favicon.svg",
"api", "auth", "health", "favicon.ico", "favicon.svg", "mcp", "oauth",
// Host-wide root files.
"robots.txt", "sitemap.xml", "ads.txt", "app-ads.txt", "security.txt",
// Future namespace prefixes.
Expand Down
30 changes: 21 additions & 9 deletions server/core/src/api-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,26 +182,38 @@ const MAX_CLI_TOKEN_BODY_BYTES = 64 * 1024;

/** Handles `POST /auth/cli/token`: the back-channel exchange of a one-time CLI
* authorization code plus PKCE verifier for a bearer token. */
function exchangeCliToken({ request, payload }: ApiContext<"cli-token-exchange">) {
/** Records the one-time redemption of a signed authorization code. Burn before
* checking possession: the conditional create fails on any second attempt, so an
* intercepted code that is replayed — or raced with a wrong verifier — fails
* closed instead of staying redeemable within its lifetime. Shared by the CLI
* and MCP code exchanges. */
export function burnOneTimeCode(
namespace: string,
id: string,
expiresAt: number,
): Effect.Effect<void, AuthError | HttpError, PrimitiveDb> {
return Effect.gen(function* () {
const config = yield* ServerConfig;
const db = yield* PrimitiveDb;
const code = yield* decodeCliAuthorizationCode(payload.code, config.auth);
// Burn the code before checking possession: the first redemption attempt
// consumes it, so an intercepted code that is replayed — or raced with a wrong
// verifier — fails closed instead of staying redeemable within its lifetime.
yield* db.put(
CLI_CODE_NAMESPACE,
code.id,
namespace,
id,
{ redeemedAt: Math.floor(Date.now() / 1000) },
{ ifNoneMatch: "*", expiresAt: code.expiresAt },
{ ifNoneMatch: "*", expiresAt },
).pipe(
Effect.mapError((error) =>
error._tag === "PrimitiveDbConflict"
? new AuthError({ status: 400, message: "Authorization code already redeemed" })
: new HttpError({ status: 500, message: "Could not record the code redemption" }),
),
);
});
}

function exchangeCliToken({ request, payload }: ApiContext<"cli-token-exchange">) {
return Effect.gen(function* () {
const config = yield* ServerConfig;
const code = yield* decodeCliAuthorizationCode(payload.code, config.auth);
yield* burnOneTimeCode(CLI_CODE_NAMESPACE, code.id, code.expiresAt);
const user = yield* verifyCliCodeExchange(code, payload.codeVerifier, payload.redirectUri, config.auth);
const cfToken = yield* decryptCliCloudflareToken(code, config.auth);
const token = yield* createSessionToken(user, config.auth);
Expand Down
12 changes: 11 additions & 1 deletion server/core/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Auth, AuthError, type AuthShape, type AuthUser } from "./auth.ts";
import { ServerConfig, type ServerConfigShape } from "./config.ts";
import { PrimitiveDb } from "./db.ts";
import { projectAccessCookie, projectAccessCookieValues } from "./cookies.ts";
import { dispatchMcpOauthRoute } from "./mcp-oauth-routes.ts";
import { acceptsHtmlPage, errorPageResponse, errorResponse } from "./error-pages.ts";
import {
appBaseUrl,
Expand Down Expand Up @@ -73,7 +74,13 @@ function handleRequest(request: HttpServerRequest.HttpServerRequest): AppEffect
const url = new URL(request.url, "http://scratchwork.local");
const config = yield* ServerConfig;

if (url.pathname.startsWith("/auth/")) {
// MCP OAuth routes join /auth/* in canonicalizing to the app origin before
// any cookie is read or issuer/audience value is derived from the request.
if (
url.pathname.startsWith("/auth/") ||
url.pathname.startsWith("/oauth/") ||
url.pathname.startsWith("/.well-known/")
) {
const redirect = canonicalAppRedirect(request, url, config);
if (redirect != null) return redirect;
}
Expand All @@ -97,6 +104,9 @@ function handleRequest(request: HttpServerRequest.HttpServerRequest): AppEffect
return yield* issueProjectAccess(request, url);
}

const mcpOauthResponse = yield* dispatchMcpOauthRoute(request, url);
if (mcpOauthResponse != null) return mcpOauthResponse;

const apiResponse = yield* dispatchApiRoute(request, url);
if (apiResponse != null) return apiResponse;

Expand Down
Loading
Loading