diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..49d371e8c8d81ca9e4c55eff307315cc1d1c2a9b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,18 @@ +build wiki: + stage: build + trigger: + include: .gitlab/ci/wiki.yml + allow_failure: true + rules: + - if: $CI_PIPELINE_SOURCE != "merge_request_event" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +build image: + stage: build + image: docker:latest + rules: + - if: $CI_PIPELINE_SOURCE != "merge_request_event" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + before_script: + - echo $CI_REGISTRY_PASSWORD | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin + script: + - docker build --tag $CI_REGISTRY_IMAGE . + - docker push $CI_REGISTRY_IMAGE diff --git a/.gitlab/ci/wiki.yml b/.gitlab/ci/wiki.yml new file mode 100644 index 0000000000000000000000000000000000000000..138debd2f5824710ea930b499cbd99a32f09c4a3 --- /dev/null +++ b/.gitlab/ci/wiki.yml @@ -0,0 +1,14 @@ +build-wiki: + image: alpine + stage: build + before_script: + - apk add --no-cache git git-subtree + script: + - git config user.email "ci@sc07.company" + - git config user.name "ci" + - git remote remove gitlab-wiki || true + - git remote add gitlab-wiki "https://ci:$CI_TOKEN@sc07.dev/sc07/fediverse-auth.wiki.git" + - git status + - git checkout main + - git pull + - git push gitlab-wiki `git subtree split -P doc main`:main --force diff --git a/backend/package.json b/backend/package.json index 99d3a280c4478562fa8338c7043fe9564dff01c3..0fa1d2c7ecb5f67a5e19390004ce78d925035f5f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,7 +21,8 @@ "express-session": "^1.18.0", "ioredis": "^5.6.1", "oidc-provider": "^8.8.1", - "openid-client": "^5.6.5" + "openid-client": "^5.6.5", + "string-strip-html": "^13.4.12" }, "devDependencies": { "@types/cookie-parser": "^1.4.7", diff --git a/backend/prisma/migrations/20250608044015_handoff_sessions/migration.sql b/backend/prisma/migrations/20250608044015_handoff_sessions/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..f0de2115dc2d2ba43b899e4b456d41e7944d6e84 --- /dev/null +++ b/backend/prisma/migrations/20250608044015_handoff_sessions/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "HandoffSession" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HandoffSession_pkey" PRIMARY KEY ("id") +); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a7f2f78d4095fdd356be1925850e5697f4e460e7..f3e5131b9d96292257dbe316286a6c4a29e16e90 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -51,3 +51,12 @@ model FediverseUser { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt } + +model HandoffSession { + id String @id @default(uuid()) + userId String // eg. https://grants.cafe/users/grant + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + expiresAt DateTime +} diff --git a/backend/src/controllers/HandoffSession.ts b/backend/src/controllers/HandoffSession.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1a3a9aae7bbc90bdcc35f321a801a0f7f2bdcb7 --- /dev/null +++ b/backend/src/controllers/HandoffSession.ts @@ -0,0 +1,82 @@ +import { HandoffSession as DBHandoffSession } from "@prisma/client"; +import { prisma } from "../lib/prisma.js"; + +export class HandoffSession { + static async create(userId: string): Promise { + // 15 minutes + const expiresAt = new Date(Date.now() + 1000 * 60 * 15); + + const session = await prisma.handoffSession.create({ + data: { + userId, + expiresAt, + }, + }); + + return new HandoffSession(session); + } + static async getExpired() { + const sessions = await prisma.handoffSession.findMany({ + where: { + expiresAt: { + lte: new Date(), + }, + }, + }); + return sessions.map((d) => new HandoffSession(d)); + } + static async get(id: string) { + const session = await prisma.handoffSession.findFirst({ + where: { + id, + expiresAt: { gt: new Date() }, + }, + }); + + if (!session) return null; + + return new HandoffSession(session); + } + + private _id: string; + private _userId: string; + private _createdAt: Date; + private _updatedAt: Date; + private _expiresAt: Date; + + private constructor(session: DBHandoffSession) { + this._id = session.id; + this._userId = session.userId; + this._createdAt = session.createdAt; + this._updatedAt = session.updatedAt; + this._expiresAt = session.expiresAt; + } + + get id() { + return this._id; + } + + get userId() { + return this._userId; + } + + get createdAt() { + return this._createdAt; + } + + get updatedAt() { + return this._updatedAt; + } + + get expiresAt() { + return this._expiresAt; + } + + getURL() { + return new URL(`/handoff/${this._id}`, process.env.OIDC_ISSUER); + } + + destroy() { + return prisma.handoffSession.delete({ where: { id: this._id } }); + } +} diff --git a/backend/src/handoff/activitypub.ts b/backend/src/handoff/activitypub.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee77ab6060cc16e050a35375be1787e6a54d4d87 --- /dev/null +++ b/backend/src/handoff/activitypub.ts @@ -0,0 +1,82 @@ +import { Actor, ChatMessage, Create, Mention, Note } from "@fedify/fedify"; +import { HandoffSession } from "../controllers/HandoffSession.js"; +import { APub } from "../lib/apub/utils.js"; +import { USER_IDENTIFIER } from "../lib/apub/federation.js"; +import { Temporal } from "@js-temporal/polyfill"; +import { stripHtml } from "string-strip-html"; + +type Message = Note | ChatMessage; + +/** + * Handle automated exchange of tokens from third party apps + */ +export class HandoffActivityPub { + async handle(actor: Actor, object: Message, create: Create) { + if (!object.content && !object.contents[0]) return; + if (!actor.id) return; + + const check = this.isObjectValid(object); + if (!check.valid) return; + + const handoff = await HandoffSession.create(actor.id.toString()); + const apub = APub.get(); + const sender = apub.ctx.getActorUri(USER_IDENTIFIER); + + if (object instanceof Note) { + await apub.sendNote( + "handoff-" + handoff.id, + actor, + new Note({ + id: apub.ctx.getObjectUri(Note, { id: "handoff-" + handoff.id }), + attribution: sender, + to: actor.id, + published: Temporal.Instant.from(handoff.createdAt.toISOString()), + replyTarget: object, + content: handoff.getURL().toString(), + tags: [ + new Mention({ + href: actor.id, + name: actor.id!.toString(), + }), + ], + }) + ); + } + + if (object instanceof ChatMessage) { + await apub.sendChatMessage( + "handoff-" + handoff.id, + actor, + new ChatMessage({ + id: apub.ctx.getObjectUri(ChatMessage, { + id: "handoff-" + handoff.id, + }), + attribution: sender, + to: actor.id, + published: Temporal.Instant.from(handoff.createdAt.toISOString()), + replyTarget: object, + content: handoff.getURL().toString(), + }) + ); + } + } + + isObjectValid( + object: Message + ): { valid: false } | { valid: true; software: string } { + const TOKEN = process.env.AUTOMATED_EXCHANGE_TOKEN; + + if (!TOKEN) return { valid: false }; + + const content = stripHtml( + object.content?.toString() || object.contents[0]?.toString() + ).result; + + if (content.indexOf(TOKEN) === -1) return { valid: false }; + + return { + valid: true, + software: "unknown", + }; + } +} diff --git a/backend/src/handoff/index.ts b/backend/src/handoff/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..46d82c7244e97d6ad1b633211e1e9d7b59753e68 --- /dev/null +++ b/backend/src/handoff/index.ts @@ -0,0 +1,30 @@ +import { HandoffActivityPub } from "./activitypub.js"; +import { router } from "./router.js"; + +export class Handoff { + private static instance: Handoff; + + static canEnable() { + return !!process.env.HANDOFF_TOKEN; + } + + static get() { + if (!process.env.HANDOFF_TOKEN) + throw new Error("HANDOFF_TOKEN not set, cannot use Handoff"); + + if (!this.canEnable()) + throw new Error( + "This should never occur, developer missed adding description throw above this line" + ); + + if (!this.instance) this.instance = new Handoff(); + + return this.instance; + } + + private constructor() {} + + static readonly HANDOFF_TOKEN = process.env.HANDOFF_TOKEN!; + readonly activitypub = new HandoffActivityPub(); + readonly router = router; +} diff --git a/backend/src/handoff/router.ts b/backend/src/handoff/router.ts new file mode 100644 index 0000000000000000000000000000000000000000..0bf5afa29ee64ad8994dcfece73991ea67f14571 --- /dev/null +++ b/backend/src/handoff/router.ts @@ -0,0 +1,58 @@ +import { Router } from "express"; +import { HandoffSession } from "../controllers/HandoffSession.js"; +import { ReactUtils } from "../lib/react.js"; +import { APub } from "../lib/apub/utils.js"; + +export const router = Router(); + +type Data = + | { state: "NOT_FOUND" } + | { state: "MISSING_RETURN" | "INVALID_RETURN" } + | { state: "INTERNAL_PROFILE_ERROR" }; + +router.get("/:id", async (req, res) => { + const session = await HandoffSession.get(req.params.id); + const Injector = new ReactUtils(req, res); + + if (!session) { + res.status(404); + Injector.render({ state: "NOT_FOUND" }); + return; + } + + const { return_uri } = req.query; + + if (!return_uri || typeof return_uri !== "string") { + res.status(400); + Injector.render({ state: "MISSING_RETURN" }); + return; + } + + try { + const url = new URL(return_uri); + if (!url.protocol.startsWith("https")) throw new Error(); + } catch (e) { + console.log(e); + res.status(400); + Injector.render({ state: "INVALID_RETURN" }); + return; + } + + const profile = await APub.buildProfile(session.userId); + if (!profile) { + res.status(500); + Injector.render({ state: "INTERNAL_PROFILE_ERROR" }); + return; + } + + await session.destroy(); + + req.session.user = { + sub: profile.sub, + handle: profile.preferred_username as any, + }; + req.session.login = undefined; + req.session.save(() => { + res.redirect(return_uri); + }); +}); diff --git a/backend/src/lib/DocLinks.ts b/backend/src/lib/DocLinks.ts new file mode 100644 index 0000000000000000000000000000000000000000..6eb01ec469c82a07e67697d21886784d2894c8a9 --- /dev/null +++ b/backend/src/lib/DocLinks.ts @@ -0,0 +1,5 @@ +export const DocLinks = { + SOURCE: "https://sc07.dev/sc07/fediverse-auth", + DOCS: "https://sc07.dev/sc07/fediverse-auth/-/wikis", + HANDOFF_DOCS: "https://sc07.dev/sc07/fediverse-auth/-/wikis/handoff", +}; diff --git a/backend/src/lib/apub/federation.ts b/backend/src/lib/apub/federation.ts index ac9c47a2b8a7958418c486cda714ee8a6a884d38..ee440bb9b00603e5a59ab2aea75233d5a0d4fce7 100644 --- a/backend/src/lib/apub/federation.ts +++ b/backend/src/lib/apub/federation.ts @@ -1,12 +1,11 @@ import { - ActivityTransformer, ChatMessage, + Create, createFederation, Endpoints, exportJwk, Follow, generateCryptoKeyPair, - getDefaultActivityTransformers, importJwk, isActor, lookupObject, @@ -19,6 +18,7 @@ import { Redis } from "ioredis"; import { FedifyTransformers } from "./transformers.js"; import { prisma } from "../prisma.js"; import { APub } from "./utils.js"; +import { Handoff } from "../../handoff/index.js"; // @auth@some.domain export const USER_IDENTIFIER = "auth"; @@ -38,6 +38,7 @@ export const federation = createFederation({ }, allowPrivateAddress: process.env.NODE_ENV === "development", + skipSignatureVerification: process.env.NODE_ENV === "development", }); federation @@ -125,6 +126,25 @@ federation object: follow, }) ); + }) + .on(Create, async (ctx, create) => { + const actor = await create.getActor(ctx); + const object = await create.getObject(ctx); + + if (!actor || !object) { + console.log("create object or actor didn't exist", { + create, + actor, + object, + }); + return; + } + + if (object instanceof Note || object instanceof ChatMessage) { + Handoff.get().activitypub.handle(actor, object, create); + } else { + console.log("create object unknown type", create, object); + } }); federation.setOutboxDispatcher( diff --git a/backend/src/lib/apub/utils.ts b/backend/src/lib/apub/utils.ts index 85ba8a4b6e4d7fa98020607529885db96046a532..2d5d159ae563b8deb6f959e1fe44846ec2494a37 100644 --- a/backend/src/lib/apub/utils.ts +++ b/backend/src/lib/apub/utils.ts @@ -26,7 +26,7 @@ type BuildObjectOpts = { }; export class APub { - private ctx: Context; + ctx: Context; constructor(ctx: Context) { this.ctx = ctx; @@ -45,6 +45,12 @@ export class APub { return USER_IDENTIFIER + "@" + new URL(process.env.OIDC_ISSUER).host; } + static get() { + return new APub( + federation.createContext(new URL("/", process.env.OIDC_ISSUER)) + ); + } + /** * Build IProfile from just an ActivityPub identifier * @param identifier @@ -199,11 +205,7 @@ export class APub { * * Not many fediverse software supports this, but Lemmy <0.19 uses this exclusively for DMs */ - private async sendChatMessage( - id: string, - target: Actor, - content: ChatMessage - ) { + async sendChatMessage(id: string, target: Actor, content: ChatMessage) { const sender = this.ctx.getActorUri(USER_IDENTIFIER); await this.ctx.sendActivity( @@ -239,7 +241,7 @@ export class APub { * * Most fediverse software supports this */ - private async sendNote(id: string, target: Actor, content: Note) { + async sendNote(id: string, target: Actor, content: Note) { const sender = this.ctx.getActorUri(USER_IDENTIFIER); await this.ctx.sendActivity( diff --git a/backend/src/lib/express.ts b/backend/src/lib/express.ts index fead809a10aa41f8a1b63fa33927a6b7bba6b436..11c7e3ec0532c4a7820a3d74440ec3b38b5b9cfe 100644 --- a/backend/src/lib/express.ts +++ b/backend/src/lib/express.ts @@ -10,9 +10,11 @@ import { errors as OIDC_Errors } from "oidc-provider"; import "../types/session-types.js"; import { APIRouter } from "./api.js"; import { integrateFederation } from "@fedify/express"; -import { federation } from "./apub/federation.js"; +import { federation, USER_IDENTIFIER } from "./apub/federation.js"; import { APub } from "./apub/utils.js"; import { APIAdminRouter } from "./api_admin.js"; +import { Handoff } from "../handoff/index.js"; +import { DocLinks } from "./DocLinks.js"; export const app = express(); @@ -45,6 +47,30 @@ app.use( }) ); +app.get("/.well-known/com.sc07.fediverse-auth", (req, res) => { + res.json({ + registration: { + mode: process.env.OIDC_REGISTRATION_TOKEN ? "private" : "public", + }, + fediverse: { + account: APub.accountHandle, + }, + handoff: Handoff.canEnable() + ? { enabled: true, token: Handoff.HANDOFF_TOKEN } + : { enabled: false }, + _meta: { + source: DocLinks.SOURCE, + docs: DocLinks.DOCS, + }, + }); +}); + +try { + app.use("/handoff", Handoff.get().router); +} catch (e) { + console.warn("Failed to activate Handoff:", (e as any)?.message || e); +} + const interactionMiddleware = ( req: express.Request, resp: express.Response diff --git a/backend/src/lib/react.ts b/backend/src/lib/react.ts new file mode 100644 index 0000000000000000000000000000000000000000..206816399e555202b8b7f3dc8dd17fa6e59e0c1e --- /dev/null +++ b/backend/src/lib/react.ts @@ -0,0 +1,55 @@ +import path from "node:path"; +import type Express from "express"; +import fs from "node:fs"; + +export class ReactUtils { + private static fileContent: string; + + static get INDEX_PATH() { + if (process.env.SERVE_FRONTEND) { + return path.join(process.env.SERVE_FRONTEND, "index.html"); + } + + return null; + } + + static get FILE_CONTENT() { + if (!this.INDEX_PATH) throw new Error("SERVE_FRONTEND is not set"); + if (typeof this.fileContent === "undefined") + this.fileContent = fs.readFileSync(this.INDEX_PATH, "utf8"); + + return this.fileContent; + } + + static render(req: Express.Request, res: Express.Response, data?: any) { + if ("x-vite-middleware" in req.headers) { + res.json({ data }); + return; + } + + const INDEX_PATH = ReactUtils.INDEX_PATH; + if (!INDEX_PATH) throw new Error("SERVE_FRONTEND is not set"); + + if (typeof data === "undefined") { + res.sendFile(INDEX_PATH); + return; + } + + res + .contentType("html") + .send( + ReactUtils.FILE_CONTENT.replace( + ``, + `` + ) + ); + } + + constructor(private req: Express.Request, private res: Express.Response) {} + + render(data?: T) { + return ReactUtils.render(this.req, this.res, data); + } +} diff --git a/backend/src/types/env.d.ts b/backend/src/types/env.d.ts index 4b681f99878bc35398c543165f0031bc2322dbd5..0cb4bf557bda03e8d207282644892ce2403c3794 100644 --- a/backend/src/types/env.d.ts +++ b/backend/src/types/env.d.ts @@ -29,6 +29,8 @@ declare global { * Trust all proxies while in development */ DEV_TRUST_PROXIES?: string; + + HANDOFF_TOKEN?: string; } } } diff --git a/doc/handoff.md b/doc/handoff.md new file mode 100644 index 0000000000000000000000000000000000000000..ccc51adc398e67c87e4a5ade11be4b918b9517c5 --- /dev/null +++ b/doc/handoff.md @@ -0,0 +1,24 @@ +# Handoff + +Handoff allows for third-party applications to initiate a login + +## Security + +* Handoff sessions **cannot** alter your account +* As the third-party app is already able to act on your behalf, this does not allow unauthorized services to act on your behalf +* Handoff sessions exist for a short amount of time and can only be used once + +## Creating a handoff session + +**This is a rundown of the process** but this specification is intended to be built upon by other projects and discovery methods (like the [fediverse.events-api](https://sc07.dev/fediverse.events/fediverse.events-api)) + +1. **Require user interaction** The user must initiate the intent to login, a good example would be clicking a button to open a service that requires Fediverse Auth (eg [Canvas](https://sc07.dev/sc07/canvas)) +2. **Request the service metadata** The metadata can be fetched from `/.well-known/com.sc07.fediverse-auth` +3. **Check if Handoff is supported** Not all fediverse-auth instances support Handoff, if supported the metadata should have a key `handoff.enabled` set to `true` +4. **Send a direct message** + * The metadata page gives the account you should send a DM to (on behalf of the user) at the `fediverse.account` key (eg `auth@auth.fediverse.events` -- note the lack of a prefixing `@`) + * The message MUST contain a short and simple explaination of what the message is (eg `I am requesting a login code`) + * The message MUST contain the handoff token returned by the metadata at the `handoff.token` key + * The message SHOULD contain what software sent the message (eg `Sent by Mastodon for iOS`). This information is helpful to the user and helpful for the service runner to find misbehaving applications. Optional, but also suggested is including the version number (eg `Sent by Mastodon for iOS (1.1.1)`) +5. **Wait for the reply** After sending the direct message to the service account, the service will reply with a URL +6. **Open the URL for the user** that was sent in the reply message, **with a return URL appended as a query parameter** (eg `https://auth.fediverse.events/handoff/EXAMPLE-TOKEN` --> `https://auth.fediverse.events/handoff/EXAMPLE-TOKEN?return_uri=https%3A%2F%2Fcanvas.fediverse.events%2Fapi%2Flogin`) \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 416a406ee8f6ecb17fcc8797dbda79262d3bc73d..a3ceb219f24cbc16399171158b83b9a79ecd2f5b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import "./App.css"; import React, { useEffect, useState } from "react"; import { PageWrapper } from "./PageWrapper"; import { api } from "./lib/utils"; +import { DocLinks } from "./lib/DocLinks"; const UserInfo = () => { const [avatarEl, setAvatarEl] = useState(null); @@ -111,8 +112,7 @@ function App() { - You can find my source{" "} - here. + You can find my source here. ); diff --git a/frontend/src/Handoff/HandoffPage.tsx b/frontend/src/Handoff/HandoffPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cfb0596feb4a3cf751a82b65b6cc86b43d895b14 --- /dev/null +++ b/frontend/src/Handoff/HandoffPage.tsx @@ -0,0 +1,94 @@ +import { Alert, Link, Typography } from "@mui/material"; +import { useSSData } from "../lib/ServerSideData"; +import { PageWrapper } from "../PageWrapper"; +import { DocLinks } from "../lib/DocLinks"; +import { Header } from "../components/Header"; + +type Data = + | { state: "NOT_FOUND" } + | { state: "MISSING_RETURN" | "INVALID_RETURN" } + | { state: "INTERNAL_PROFILE_ERROR" }; + +export const HandoffPage = () => { + const ssdata = useSSData(); + + switch (ssdata.state) { + case "NOT_FOUND": + return ; + case "MISSING_RETURN": + case "INVALID_RETURN": + return ; + case "INTERNAL_PROFILE_ERROR": + return ; + } + + return
UNKNOWN STATE {JSON.stringify(ssdata)}
; +}; + +const InfoPage = () => { + return ( + +
+ + + The handoff link you followed does not exist +
+ ...or the session expired +
+ + + This link was sent to you as a response to a third party app requesting + it on your behalf. +
+
+ The links created are single use and used to prove your identity, + allowing for easy signin on cooperating apps +
+ +
    +
  • + What is Fediverse Auth? +
  • +
  • + Handoff Documentation +
  • +
+ + ); +}; + +const ReturnError = ({ + error, +}: { + error: "MISSING_RETURN" | "INVALID_RETURN"; +}) => { + return ( + +
+ + {error === "MISSING_RETURN" ? ( + + Missing return URL +
+ ?return_uri needs to be set on this request +
+ ) : ( + + Return URL is invalid +
+ ?return_uri needs to be an absolute URL +
+ )} + + ); +}; + +const InternalError = () => { + return ( + +
+ + Internal error, cannot continue + + ); +}; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..05d93df02218af4ab83983b487248c9da37c9298 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,24 @@ +import { Box, Typography } from "@mui/material"; + +export const Header = () => { + return ( + + + Fediverse Auth + + + ); +}; diff --git a/frontend/src/lib/DocLinks.ts b/frontend/src/lib/DocLinks.ts new file mode 100644 index 0000000000000000000000000000000000000000..efe314617cc5ac9f2e7c9d44c1fc9d8f98b49593 --- /dev/null +++ b/frontend/src/lib/DocLinks.ts @@ -0,0 +1,4 @@ +export const DocLinks = { + SOURCE: "https://sc07.dev/sc07/fediverse-auth", + HANDOFF_DOCS: "https://sc07.dev/sc07/fediverse-auth/-/wikis/handoff", +}; diff --git a/frontend/src/lib/ServerSideData.tsx b/frontend/src/lib/ServerSideData.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d8b294f8eec975db27d0760a2c3231c9e58385db --- /dev/null +++ b/frontend/src/lib/ServerSideData.tsx @@ -0,0 +1,43 @@ +import { + createContext, + PropsWithChildren, + useContext, + useEffect, + useState, +} from "react"; + +const context = createContext(undefined); + +export const useSSData = () => useContext(context); + +export const ServerSideContext = ({ children }: PropsWithChildren) => { + if (import.meta.env.PROD) { + const value: any = document.getElementById("ss-data") + ? JSON.parse(document.getElementById("ss-data")!.innerText) + : undefined; + return {children}; + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [ready, setReady] = useState(false); + // eslint-disable-next-line react-hooks/rules-of-hooks + const [value, setValue] = useState(undefined); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (import.meta.env.DEV) { + fetch("/_dev/ssdata") + .then((a) => a.json()) + .then((data) => { + setValue(data); + setReady(true); + }); + } + }, []); + + return ( + + {ready ? children : <>Dev: waiting for server data} + + ); +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e72849211ee6bad295de9f198ee5bba22affbe87..ad8f6b63403641bc614e64cfebbab2ee4f36e4cc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,6 +7,8 @@ import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { LoginPage } from "./Login/Login.tsx"; import { InteractionPage } from "./Interaction/InteractionPage.tsx"; import { LogoutPage } from "./Logout/Logout.tsx"; +import { HandoffPage } from "./Handoff/HandoffPage.tsx"; +import { ServerSideContext } from "./lib/ServerSideData.tsx"; const theme = createTheme({ palette: { mode: "dark" } }); const router = createBrowserRouter([ @@ -26,13 +28,19 @@ const router = createBrowserRouter([ path: "/interaction/:id", element: , }, + { + path: "/handoff/:id", + element: , + }, ]); ReactDOM.createRoot(document.getElementById("root")!).render( - - - - + + + + + + ); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ddc8366a2992ae0bd774fd2ab315e9dfa80b286d..8afe3f413bc94b7f2e15829034cc202eb6880480 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -22,6 +22,43 @@ function dev_middleware(API_BACKEND_HOST: string | undefined): PluginOption { throw new Error("DEV_BACKEND_HOST is not specified"); } + let data: any; + + server.middlewares.use("/_dev/ssdata", async (req, res) => { + res.setHeader("Content-Type", "application/json"); + try { + res.write(JSON.stringify(data)); + } catch (e) { + res.write("{}"); + } + res.end(); + }); + + server.middlewares.use("/handoff", async (req, res, next) => { + const sessionId = (req.url || "").slice(1).split("/")[0]; + + const middle = await fetch( + new URL("/handoff/" + sessionId, API_BACKEND_HOST), + { + redirect: "manual", + headers: { + "x-vite-middleware": "yes", + }, + } + ); + + if (middle.headers.get("location")) { + res.statusCode = 302; + res.setHeader("Location", middle.headers.get("location")!.toString()); + res.end(); + return; + } + + data = (await middle.json())?.data; + + next(); + }); + // the backend has interaction middleware to sync login sessions w/ oidc-provider // this adds the middleware to vite server.middlewares.use("/interaction", async (req, res, next) => { @@ -44,7 +81,7 @@ function dev_middleware(API_BACKEND_HOST: string | undefined): PluginOption { // if the middleware is redirecting we need to follow the redirect if (middle.headers.get("Location")) { - let location = new URL( + const location = new URL( middle.headers.get("Location")!, "http://" + req.headers.host ); @@ -88,9 +125,15 @@ export default defineConfig(({ mode }) => { plugins: [react(), dev_middleware(env.DEV_BACKEND_HOST)], server: { proxy: { - "/.well-known": env.DEV_BACKEND_HOST, "/api": env.DEV_BACKEND_HOST, + + // CSRF handling "/logout": env.DEV_BACKEND_HOST, + + // fediverse endpoints + "/.well-known": env.DEV_BACKEND_HOST, + "/x": env.DEV_BACKEND_HOST, + "/inbox": env.DEV_BACKEND_HOST, }, }, }; diff --git a/package-lock.json b/package-lock.json index d4b3802073f9bf5a4a25fd50a3675ffd90c8f3da..e6689ad03d80e826287b461347b244cb471258d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,8 @@ "express-session": "^1.18.0", "ioredis": "^5.6.1", "oidc-provider": "^8.8.1", - "openid-client": "^5.6.5" + "openid-client": "^5.6.5", + "string-strip-html": "^13.4.12" }, "devDependencies": { "@types/cookie-parser": "^1.4.7", @@ -1470,6 +1471,19 @@ "@types/koa": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz", + "integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/luxon": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz", @@ -2126,6 +2140,17 @@ "node": ">= 0.12.0" } }, + "node_modules/codsen-utils": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/codsen-utils/-/codsen-utils-1.6.7.tgz", + "integrity": "sha512-M+9D3IhFAk4T8iATX62herVuIx1sp5kskWgxEegKD/JwTTSSGjGQs5Q5J4vVJ4mLcn1uhfxDYv6Yzr8zleHF3w==", + "dependencies": { + "rfdc": "^1.4.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "dev": true, @@ -3268,6 +3293,21 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ] + }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", @@ -3792,6 +3832,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -4541,6 +4586,52 @@ "node": ">= 0.6" } }, + "node_modules/ranges-apply": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-7.0.19.tgz", + "integrity": "sha512-imA03KuTSuSpQtq9SDhavUz7BtiddCPj+fsYM/XpdypRN/s8vyTayKzni6m5nYs7VMds1kSNK1V3jfwVrPUWBQ==", + "dependencies": { + "ranges-merge": "^9.0.18", + "tiny-invariant": "^1.3.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/ranges-merge": { + "version": "9.0.18", + "resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-9.0.18.tgz", + "integrity": "sha512-2+6Eh4yxi5sudUmvCdvxVOSdXIXV+Brfutw8chhZmqkT0REqlzilpyQps1S5n8c7f0+idblqSAHGahTbf/Ar5g==", + "dependencies": { + "ranges-push": "^7.0.18", + "ranges-sort": "^6.0.13" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/ranges-push": { + "version": "7.0.18", + "resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-7.0.18.tgz", + "integrity": "sha512-wzGHipEklSlY0QloQ88PNt+PkTURIB42PLLcQGY+WyYBlNpnrzps6EYooD3RqNXtdqMQ9kR8IVaF9itRYtuzLA==", + "dependencies": { + "codsen-utils": "^1.6.7", + "ranges-sort": "^6.0.13", + "string-collapse-leading-whitespace": "^7.0.9", + "string-trim-spaces-only": "^5.0.12" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/ranges-sort": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-6.0.13.tgz", + "integrity": "sha512-M3P0/dUnU3ihLPX2jq0MT2NJA1ls/q6cUAUVPD28xdFFqm3VFarPjTKKhnsBSvYCpZD8HdiElAGAyoPu6uOQjA==", + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", @@ -4726,6 +4817,11 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, "node_modules/rimraf": { "version": "3.0.2", "dev": true, @@ -5014,6 +5110,51 @@ "node": ">= 0.8" } }, + "node_modules/string-collapse-leading-whitespace": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-7.0.9.tgz", + "integrity": "sha512-lEuTHlogBT9PWipfk0FOyvoMKX8syiE03QoFk5MDh8oS0AJ2C07IlstR5cGkxz48nKkOIuvkC28w9Rx/cVRNDg==", + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/string-left-right": { + "version": "6.0.20", + "resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-6.0.20.tgz", + "integrity": "sha512-dz2mUgmsI7m/FMe+BoxZ2+73X1TUoQvjCdnq8vbIAnHlvWfVZleNUR+lw+QgHA2dlJig+hUWC9bFYdNFGGy2bA==", + "dependencies": { + "codsen-utils": "^1.6.7", + "rfdc": "^1.4.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/string-strip-html": { + "version": "13.4.12", + "resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-13.4.12.tgz", + "integrity": "sha512-mr1GM1TFcwDkYwLE7TNkHY+Lf3YFEBa19W9KntZoJJSbrKF07W4xmLkPnqf8cypEGyr+dc1H9hsdTw5VSNVGxg==", + "dependencies": { + "@types/lodash-es": "^4.17.12", + "codsen-utils": "^1.6.7", + "html-entities": "^2.5.2", + "lodash-es": "^4.17.21", + "ranges-apply": "^7.0.19", + "ranges-push": "^7.0.18", + "string-left-right": "^6.0.20" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/string-trim-spaces-only": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-5.0.12.tgz", + "integrity": "sha512-Un5nIO1av+hzfnKGmY+bWe0AD4WH37TuDW+jeMPm81rUvU2r3VPRj9vEKdZkPmuhYAMuKlzarm7jDSKwJKOcpQ==", + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "dev": true, @@ -5068,6 +5209,11 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",