alinea
Version:
[](https://npmjs.org/package/alinea) [](https://packagephobia.com/result?p=alinea)
215 lines (213 loc) • 7.46 kB
JavaScript
import {
PLazy
} from "../../chunks/chunk-IKINPSS5.js";
import {
package_default
} from "../../chunks/chunk-KWP47LDR.js";
import "../../chunks/chunk-U5RRZUYZ.js";
// src/cloud/server/CloudAuthServer.ts
import { fetch, Response } from "@alinea/iso";
import { router } from "alinea/backend/router/Router";
import {
Connection,
HttpError,
outcome,
Workspace
} from "alinea/core";
import { verify } from "alinea/core/util/JWT";
import { AuthResultType } from "../AuthResult.js";
import { cloudConfig } from "./CloudConfig.js";
var RemoteUnavailableError = class extends Error {
};
var publicKey = PLazy.from(loadPublicKey);
function loadPublicKey(retry = 0) {
return fetch(cloudConfig.jwks).then(async (res) => {
if (res.status !== 200)
throw new HttpError(res.status, await res.text());
return res.json();
}).then((jwks) => {
const key = jwks.keys[0];
if (!key)
throw new HttpError(500, "No signature key found");
return key;
}).catch((error) => {
if (retry < 3)
return loadPublicKey(retry + 1);
publicKey = PLazy.from(loadPublicKey);
throw new RemoteUnavailableError("Remote unavailable", { cause: error });
});
}
var COOKIE_NAME = "alinea.cloud";
var CloudAuthServer = class {
constructor(options) {
this.options = options;
const { apiKey, config } = options;
this.dashboardUrl = config.dashboard?.dashboardUrl;
const matcher = router.startAt(Connection.routes.base);
this.router = router(
// We start by asking our backend whether we have:
// - a logged in user => return the user so we can create a session
// - no user, but a valid api key => we can redirect to cloud login
// - no api key => display a message to setup backend
matcher.get(Connection.routes.base + "/auth.cloud").map(async ({ request }) => {
return this.authResult(request);
}).map(router.jsonResponse),
// The cloud server will request a handshake confirmation on this route
matcher.get(Connection.routes.base + "/auth/handshake").map(async ({ url }) => {
const handShakeId = url.searchParams.get("handshake_id");
if (!handShakeId)
throw new HttpError(
400,
"Provide a valid handshake id to initiate handshake"
);
const body = {
handshake_id: handShakeId,
status: {
version: package_default.version,
roles: [
{
key: "editor",
label: "Editor",
description: "Can view and edit all pages"
}
],
sourceDirectories: Object.values(config.workspaces).flatMap((workspace) => {
const { source, mediaDir } = Workspace.data(workspace);
return [source, mediaDir];
}).filter(Boolean)
}
};
if (process.env.VERCEL) {
body.git = {
hosting: "vercel",
env: process.env.VERCEL_ENV,
url: process.env.VERCEL_URL,
provider: process.env.VERCEL_GIT_PROVIDER,
repo: process.env.VERCEL_GIT_REPO_SLUG,
owner: process.env.VERCEL_GIT_REPO_OWNER,
branch: process.env.VERCEL_GIT_COMMIT_REF
};
}
const res = await fetch(cloudConfig.handshake, {
method: "POST",
body: JSON.stringify(body),
headers: {
"content-type": "application/json",
accept: "application/json",
authorization: `Bearer ${apiKey}`
}
}).catch((e) => {
throw new HttpError(500, `Could not reach handshake api: ${e}`);
});
if (res.status !== 200)
throw new HttpError(
res.status,
`Handshake failed: ${await res.text()}`
);
return new Response("alinea cloud handshake");
}),
// If the user followed through to the cloud login page it should
// redirect us here with a token
matcher.get(Connection.routes.base + "/auth").map(async ({ request, url }) => {
if (!apiKey)
throw new HttpError(500, "No api key set");
const token = url.searchParams.get("token");
if (!token)
throw new HttpError(400, "Token required");
const user = await verify(token, await publicKey);
const target = new URL(this.dashboardUrl, url);
return router.redirect(target.href, {
status: 302,
headers: {
"set-cookie": router.cookie({
name: COOKIE_NAME,
value: token,
domain: target.hostname,
path: "/",
secure: target.protocol === "https:",
httpOnly: true,
sameSite: "strict"
})
}
});
}),
// The logout route unsets our cookies
matcher.get(Connection.routes.base + "/auth/logout").map(async ({ url, request }) => {
const target = new URL(this.dashboardUrl, url);
try {
const { token } = await this.contextFor(request);
if (token) {
await fetch(cloudConfig.logout, {
method: "POST",
headers: { authorization: `Bearer ${token}` }
});
}
} catch (e) {
console.error(e);
}
return router.redirect(target.href, {
status: 302,
headers: {
"set-cookie": router.cookie({
name: COOKIE_NAME,
value: "",
domain: target.hostname,
path: "/",
secure: target.protocol === "https:",
httpOnly: true,
sameSite: "strict",
expires: /* @__PURE__ */ new Date(0)
})
}
});
}),
matcher.all(Connection.routes.base + "/*").map(async ({ request }) => {
try {
const { user } = await this.contextFor(request);
} catch (error) {
if (error instanceof HttpError)
throw error;
throw new HttpError(401, "Unauthorized", { cause: error });
}
}).map(router.jsonResponse)
).recover(router.reportError);
}
router;
context = /* @__PURE__ */ new WeakMap();
dashboardUrl;
async authResult(request) {
if (!this.options.apiKey)
return {
type: AuthResultType.MissingApiKey,
setupUrl: cloudConfig.setup
};
const [ctx, err] = await outcome(this.contextFor(request));
if (ctx)
return { type: AuthResultType.Authenticated, user: ctx.user };
const token = this.options.apiKey.split("_")[1];
if (!token)
return {
type: AuthResultType.MissingApiKey,
setupUrl: cloudConfig.setup
};
return {
type: AuthResultType.UnAuthenticated,
redirect: `${cloudConfig.auth}?token=${token}`
};
}
async contextFor(request) {
if (this.context.has(request))
return this.context.get(request);
const cookies = request.headers.get("cookie");
if (!cookies)
throw new HttpError(401, "Unauthorized - no cookies");
const token = cookies.split(";").map((c) => c.trim()).find((c) => c.startsWith(`${COOKIE_NAME}=`));
if (!token)
throw new HttpError(401, `Unauthorized - no ${COOKIE_NAME}`);
const jwt = token.slice(`${COOKIE_NAME}=`.length);
return { token: jwt, user: await verify(jwt, await publicKey) };
}
};
export {
CloudAuthServer
};