import { randomBytes } from "node:crypto";
import { httpLogger } from "@atproto/pds";
export const PREAUTHORIZE_PATH = "/_preauthorize";
export const MAX_ACCOUNT_AGE_MS = 5 * 60 * 1000;
const MAX_BODY_BYTES = 8 * 1024;
class Refusal extends Error {
constructor(status, error, message) {
super(message);
this.status = status;
this.error = error;
}
}
const refuse = (message) => new Refusal(403, "PreauthorizeRefused", message);
export const clientsFromEnv = (raw = process.env.TANGLED_PREAUTHORIZE_CLIENTS) =>
(raw ?? "")
.split(",")
.map((client) => client.trim())
.filter(Boolean);
// everything the route calls. a pds that dropped one leaves the route off and logs it
const requiredApi = (pds) => {
const provider = pds.ctx?.oauthProvider;
return {
"app.post": pds.app?.post,
"ctx.authVerifier.adminToken": pds.ctx?.authVerifier?.adminToken,
"ctx.accountManager.getAccount": pds.ctx?.accountManager?.getAccount,
"oauthProvider.clientManager.getClient": provider?.clientManager?.getClient,
"oauthProvider.accountManager.getAccount": provider?.accountManager?.getAccount,
"oauthProvider.accountManager.setAuthorizedClient":
provider?.accountManager?.setAuthorizedClient,
"oauthProvider.requestManager.peekClientId": provider?.requestManager?.peekClientId,
"oauthProvider.requestManager.get": provider?.requestManager?.get,
"oauthProvider.requestManager.setAuthorized": provider?.requestManager?.setAuthorized,
"oauthProvider.requestManager.delete": provider?.requestManager?.delete,
"oauthProvider.tokenManager.listAccountTokens": provider?.tokenManager?.listAccountTokens
};
};
export const missingApi = (pds) => {
const missing = Object.entries(requiredApi(pds))
.filter(([, value]) => typeof value !== "function")
.map(([name]) => name);
if (typeof pds.ctx?.oauthProvider?.issuer !== "string") missing.push("oauthProvider.issuer");
return missing;
};
const readJson = async (req) => {
let size = 0;
const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw new Refusal(413, "PayloadTooLarge", "body too large");
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new Refusal(400, "InvalidRequest", "body is not json");
}
};
export const mountPreauthorize = (pds, clients = clientsFromEnv(), now = Date.now) => {
const missing = missingApi(pds);
if (missing.length > 0) {
httpLogger.error({ missing }, "tangled preauthorize is off, the pds lacks what it needs");
return;
}
const { authVerifier, accountManager: actors, oauthProvider: provider } = pds.ctx;
const authorize = async (req) => {
const { did, requestUri } = (await readJson(req)) ?? {};
if (typeof did !== "string" || typeof requestUri !== "string") {
throw new Refusal(400, "InvalidRequest", "did and requestUri are required");
}
const clientId = await provider.requestManager.peekClientId(requestUri);
if (!clientId) throw refuse("unknown request");
if (!clients.includes(clientId)) throw refuse("client is not allowed");
const actor = await actors.getAccount(did);
if (!actor) throw refuse("unknown account");
if (now() - Date.parse(actor.createdAt) > MAX_ACCOUNT_AGE_MS)
throw refuse("account is not new");
const { account, authorizedClients } = await provider.accountManager.getAccount(did);
if (authorizedClients.size > 0) throw refuse("account already authorized a client");
if ((await provider.tokenManager.listAccountTokens(did)).length > 0) {
throw refuse("account already has oauth tokens");
}
const client = await provider.clientManager.getClient(clientId);
const deviceId = `dev-${randomBytes(16).toString("hex")}`;
const { parameters } = await provider.requestManager.get(requestUri, deviceId, clientId);
const hint = parameters.login_hint;
if (hint && hint !== account.did && hint !== account.handle) {
await provider.requestManager.delete(requestUri);
throw refuse("request is for another account");
}
const metadata = {
userAgent: req.headers["user-agent"] ?? null,
ipAddress: req.ip ?? req.socket.remoteAddress ?? "",
lastSeenAt: new Date(now())
};
const code = await provider.requestManager.setAuthorized(
requestUri,
client,
account,
deviceId,
metadata
);
await provider.accountManager.setAuthorizedClient(account, client, {
authorizedScopes: parameters.scope?.split(" ") ?? []
});
return { code, state: parameters.state, iss: provider.issuer };
};
pds.app.post(PREAUTHORIZE_PATH, async (req, res) => {
try {
await authVerifier.adminToken({ req, res });
} catch {
return res
.status(401)
.json({ error: "AuthenticationRequired", message: "admin auth required" });
}
try {
res.json(await authorize(req));
} catch (err) {
if (err instanceof Refusal)
return res.status(err.status).json({ error: err.error, message: err.message });
if (err?.status < 500 && typeof err.error === "string") {
return res
.status(403)
.json({ error: "PreauthorizeRefused", message: err.error_description ?? err.error });
}
httpLogger.error({ err }, "tangled preauthorize failed");
res.status(500).json({ error: "InternalServerError", message: "Internal Server Error" });
}
});
};