diff --git a/Caddyfile b/Caddyfile --- a/Caddyfile +++ b/Caddyfile @@ -1,13 +1,15 @@ { - admin off + admin off } localhost { - handle_path /cub* { - reverse_proxy localhost:3000 - } - - handle { - reverse_proxy localhost:5173 - } + tls internal + + handle_path /cub* { + reverse_proxy http://127.0.0.1:3000 + } + + handle { + reverse_proxy http://127.0.0.1:5173 + } } diff --git a/centralUserBackend/cartero/root.cartero b/centralUserBackend/cartero/root.cartero --- a/centralUserBackend/cartero/root.cartero +++ b/centralUserBackend/cartero/root.cartero @@ -1,3 +1,3 @@ version = 1 -url = "https://localhost" +url = "https://localhost/cub/" method = "GET" diff --git a/centralUserBackend/src/index.ts b/centralUserBackend/src/index.ts --- a/centralUserBackend/src/index.ts +++ b/centralUserBackend/src/index.ts @@ -1,109 +1,189 @@ +import {activeUserSessions, getPDSOAuthClient, isValidUserSession, PDS_AUTH_SCOPE, validateUserSessionMiddleware} from "./session"; + +export const PUBLIC_URL = "https://localhost"; + const server = Bun.serve({ port: 3000, routes: { "/": { GET(request) { - return new Response("Hello from the HarmonyChat Central User Backend!") - } + return new Response("Hello from the HarmonyChat Central User Backend!"); + }, }, "/auth/new": { // create a new session, returns a login redirection url async POST(request) { try { - const { handle } = await (request.json() as Promise); + const { handle } = await (await request.json() as Promise); - if (!handle || typeof handle !== "string") { - return Response.json( - { error: "Handle is required" }, - { status: 400 } - ); - } + if (!handle || typeof handle !== "string") { + return Response.json({ error: "Handle is required" }, { status: 400 }); + } - const client = await getOAuthClient(); + const client = await getPDSOAuthClient(); - // Resolves handle, finds their auth server, returns authorization URL - const authUrl = await client.authorize(handle, { - scope: SCOPE, - }); + // Resolves handle, finds their auth server, returns authorization URL + const authUrl = await client.authorize(handle, { + scope: PDS_AUTH_SCOPE, + }); - return Response.json({ redirectUrl: authUrl.toString() }); - } catch (error) { - return Response.json( - { error: error instanceof Error ? error.message : "Login failed" }, - { status: 500 } - ); - } - } + return Response.json({ redirectUrl: authUrl.toString() }); + } catch (error) { + return Response.json({ error: error instanceof Error ? error.message : "Login failed" }, { status: 500 }); + } + }, + }, + "/auth/callback": { + // handle newly created PDS OAuth secrets + async GET(request) { + try { + const params = new URLSearchParams(new URL(request.url).searchParams); + const client = await getPDSOAuthClient(); + + const { session } = await client.callback(params); + + const headers = new Headers(); + headers.append("Location", new URL("/app", PUBLIC_URL).toString()); + + function randomString(length: number) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*_-+=.~"; + const arr = new Uint8Array(length); + crypto.getRandomValues(arr); + return Array.from(arr) + .map((n) => chars[n % chars.length]) + .join(""); + } + + const sessionToken = randomString(128); + + if (activeUserSessions.has(session.did)) { + activeUserSessions.set(session.did, [ + ...(activeUserSessions.get(session.did) || []), + { sessionToken, validUntil: Date.now() + 604_800_000 /* 7 days in ms */ }, + ]); + } else { + activeUserSessions.set(session.did, [{ sessionToken, validUntil: Date.now() + 604_800_000 /* 7 days in ms */ }]); + } + + const cookieAttributes = [ + "HttpOnly", + "Path=/", + "SameSite=Lax", + `Max-Age=${60 * 60 * 24 * 7}`, // 1 week + process.env.NODE_ENV === "production" ? "Secure" : "", + ] + .filter(Boolean) + .join("; "); + + headers.append("Set-Cookie", `did=${session.did}; ${cookieAttributes}`); + headers.append("Set-Cookie", `session_token=${sessionToken}; ${cookieAttributes}`); + + return new Response(null, { + status: 302, + headers: headers, + }); + } catch (error) { + console.error("OAuth callback error:", error); + return Response.redirect(new URL("/oauth/error/login_failed", PUBLIC_URL).href); + } + }, + }, + "/auth/check": { + GET(request) { + const did = request.cookies.get("did"); + const sessionToken = request.cookies.get("session_token"); + + if (!did) { + console.warn("Request missing did"); + return Response.json({ ok: false }); + } + + if (!sessionToken) { + console.warn("Request missing sessionToken"); + return Response.json({ ok: false }); + } + + if (isValidUserSession(did, sessionToken)) { + return Response.json({ authenticated: true }); + } + + return Response.json({ authenticated: false }); + }, }, "/auth/logout": { // destroy a session token - POST(request) { - return Response.json({ no: true }) - } + POST: validateUserSessionMiddleware(({did, sessionToken}) => { + activeUserSessions.set( + did, + (activeUserSessions.get(did) || []).filter((session) => session.sessionToken !== sessionToken), + ); + + return Response.json({ success: true }); + }), }, "/user/profiles/detailed": { // return an array of a user's profiles with profile data GET(request) { - return Response.json({ no: true }) - } + return Response.json({ no: true }); + }, }, "/user/profiles": { - // return a string array of all the profile IDs that the user owns + // return a string array of all the profile IDs that the user owns GET(request) { - return Response.json({ no: true }) - } + return Response.json({ no: true }); + }, }, "/user/profile": { // get a user profile by it's PID (Profile ID) GET(request) { - return Response.json({ no: true }) + return Response.json({ no: true }); }, // create a new user profile POST(request) { - return Response.json({ no: true }) + return Response.json({ no: true }); }, // update a user profile PUT(request) { - return Response.json({ no: true }) + return Response.json({ no: true }); }, // delete a user profile DELETE(request) { - return Response.json({ no: true }) - } + return Response.json({ no: true }); + }, }, "/user/avatar": { // get the avatar GET(request) { - return Response.json({ no: true }) + return Response.json({ no: true }); }, // set the avatar PUT(request) { - return Response.json({ no: true }) + return Response.json({ no: true }); }, // set the avatar to a default DELETE(request) { - return Response.json({ no: true }) - } + return Response.json({ no: true }); + }, }, "/user/communities": { // list of community IDs GET(request) { - return Response.json({ no: true }) - } + return Response.json({ no: true }); + }, }, "/user/join-community": { // join the community POST(request) { - return Response.json({ no: true }) - } + return Response.json({ no: true }); + }, }, "/user/community": { // leave the community DELETE(request) { - return Response.json({ no: true }) - } + return Response.json({ no: true }); + }, }, - } -}) + }, +}); -console.log(`HarmonyChat Central User Backend listening on port ${server.port}`) +console.log(`HarmonyChat Central User Backend listening on port ${server.port}`); diff --git a/centralUserBackend/src/session.ts b/centralUserBackend/src/session.ts --- a/centralUserBackend/src/session.ts +++ b/centralUserBackend/src/session.ts @@ -1,13 +1,112 @@ // user id -> did:harmony:[32x randchar] +import { NodeOAuthClient, buildAtprotoLoopbackClientMetadata } from "@atproto/oauth-client-node"; +import type { NodeSavedSession, NodeSavedState } from "@atproto/oauth-client-node"; + +export const PDS_AUTH_SCOPE = "atproto"; + // GCI Sessions // sessionToken -> domain const activeGCISessions = new Map(); + +export function isValidGCISession(sessionToken: string, domain: string): boolean { + if (!domain || !sessionToken) return false; + + if (activeGCISessions.get(sessionToken) === domain) return true; + + return false; +} + // Frontend Sessions // sessionToken -> domain const activeFrontendSessions = new Map(); + +export function isValidFrontendSession(sessionToken: string, domain: string): boolean { + if (!domain || !sessionToken) return false; + + if (activeGCISessions.get(sessionToken) === domain) return true; + + return false; +} + // User Sessions -// sessionToken -> did -const activeUserSessions = new Map(); +// did -> { sessionToken: string, validUntil: unix epoch ms } +const activeUserSessions = new Map(); + +export function isValidUserSession(did: string, sessionToken: string): boolean { + if (!did || !sessionToken) return false; + + const session = activeUserSessions.get(did)?.find((session) => session.sessionToken === sessionToken); + + if (session) { + if (session.validUntil > Date.now()) { + return true; + } + + activeUserSessions.set(did, activeUserSessions.get(did)?.filter((s) => s.sessionToken !== session.sessionToken) || []); + } + + return false; +} + +export function validateUserSessionMiddleware( + callback: (options: { request: Req; did: string; sessionToken: string }) => Res, +) { + return (request: Req): Res => { + const did = request.cookies.get("did"); + const sessionToken = request.cookies.get("session_token"); + + if (!did || !sessionToken) return Response.json({ unauthorized: true }, { status: 401 }); + + if (isValidUserSession(did, sessionToken)) return callback({ did, sessionToken, request }); + + return Response.json({ unauthorized: true }, { status: 401 }); + }; +} + // PDS Sessions -const activePDSSessions = new Map(); +const activePDSSessions = { + stateStore: new Map(), + sessionStore: new Map(), +}; + +let PDSClient: NodeOAuthClient | null = null; + +export async function getPDSOAuthClient(): Promise { + if (PDSClient) return PDSClient; + + PDSClient = new NodeOAuthClient({ + clientMetadata: buildAtprotoLoopbackClientMetadata({ + scope: PDS_AUTH_SCOPE, + redirect_uris: [`${process.env.PUBLIC_URL || "http://127.0.0.1"}/oauth/callback`], + }), + + stateStore: { + async get(key: string) { + return activePDSSessions.stateStore.get(key); + }, + async set(key: string, value: NodeSavedState) { + activePDSSessions.stateStore.set(key, value); + }, + async del(key: string) { + activePDSSessions.stateStore.delete(key); + }, + }, + + sessionStore: { + async get(key: string) { + return activePDSSessions.sessionStore.get(key); + }, + async set(key: string, value: NodeSavedSession) { + activePDSSessions.sessionStore.set(key, value); + }, + async del(key: string) { + activePDSSessions.sessionStore.delete(key); + }, + }, + }); + + return PDSClient; +} + +export { activeGCISessions, activeFrontendSessions, activeUserSessions, activePDSSessions }; diff --git a/frontend/biome.json b/frontend/biome.json deleted file mode 100644 --- a/frontend/biome.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "json": { - "parser": { - "allowComments": true - } - }, - "linter": { - "rules": { - "suspicious": { - "noTsIgnore": "off", - "noAsyncPromiseExecutor": "off" - }, - "a11y": { - "useValidAriaRole": "off" - }, - "style": { - "noNonNullAssertion": "off" - } - } - }, - "formatter": { - "indentStyle": "space", - "indentWidth": 2, - "lineEnding": "lf", - "lineWidth": 160 - } -} diff --git a/frontend/src/lib/authentication.ts b/frontend/src/lib/authentication.ts deleted file mode 100644 --- a/frontend/src/lib/authentication.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - NodeOAuthClient, - buildAtprotoLoopbackClientMetadata, -} from "@atproto/oauth-client-node"; -import type { - NodeSavedSession, - NodeSavedState, -} from "@atproto/oauth-client-node"; -import { getCookie } from "@solidjs/start/http"; - -export const SCOPE = "atproto"; - -export const globalAuth = globalThis as unknown as { - stateStore: Map; - sessionStore: Map; - // sessionToken -> DID - sessionTokenStore: Map -}; -globalAuth.stateStore ??= new Map(); -globalAuth.sessionStore ??= new Map(); -globalAuth.sessionTokenStore ??= new Map() - -let client: NodeOAuthClient | null = null; - -export function isValidSession(): boolean { - const did = getCookie("did"); - const sessionToken = getCookie("session_token") - - if (!did || !sessionToken) return false; - - if (globalAuth.sessionTokenStore.get(sessionToken) === did) - return true; - - return false -} - -export async function getOAuthClient(): Promise { - if (client) return client; - - client = new NodeOAuthClient({ - clientMetadata: buildAtprotoLoopbackClientMetadata({ - scope: SCOPE, - redirect_uris: [`${process.env.PUBLIC_URL || "http://127.0.0.1"}/oauth/callback`], - }), - - stateStore: { - async get(key: string) { - return globalAuth.stateStore.get(key); - }, - async set(key: string, value: NodeSavedState) { - globalAuth.stateStore.set(key, value); - }, - async del(key: string) { - globalAuth.stateStore.delete(key); - }, - }, - - sessionStore: { - async get(key: string) { - return globalAuth.sessionStore.get(key); - }, - async set(key: string, value: NodeSavedSession) { - globalAuth.sessionStore.set(key, value); - }, - async del(key: string) { - globalAuth.sessionStore.delete(key); - }, - }, - }); - - return client; -} diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts deleted file mode 100644 --- a/frontend/src/lib/session.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { OAuthSession } from "@atproto/oauth-client-node"; -import { getCookie } from "@solidjs/start/http"; -import { getOAuthClient } from "./authentication"; - -export async function getSession(): Promise { - const did = getDID(); - if (!did) return null; - - try { - const client = await getOAuthClient(); - return await client.restore(did); - } catch { - return null; - } -} - -export function getDID(): string | undefined { - return getCookie("did"); -} diff --git a/frontend/src/routes/(default).tsx b/frontend/src/routes/(default).tsx --- a/frontend/src/routes/(default).tsx +++ b/frontend/src/routes/(default).tsx @@ -1,35 +1,59 @@ -import { Component, ParentProps } from "solid-js"; -import styles from "./(default).module.scss" +import { Component, createResource, ParentProps } from "solid-js"; +import styles from "./(default).module.scss"; import UKButton from "@ewsgit/uikit-solid/src/components/button/UKButton.jsx"; -import { createAsync, query, useNavigate } from "@solidjs/router"; -import ACCOUNT_CIRCLE_ICON from "@material-symbols/svg-700/outlined/account_circle.svg" +import { useNavigate } from "@solidjs/router"; +import ACCOUNT_CIRCLE_ICON from "@material-symbols/svg-700/outlined/account_circle.svg"; import OPEN_IN_NEW_ICON from "@material-symbols/svg-700/outlined/open_in_new.svg"; -import { isValidSession } from "~/lib/authentication"; - -const getIsLoggedIn = query(async () => { - "use server"; - - return isValidSession(); -}, "loggedIn") +import { CUB_HOSTNAME } from "~/lib/cub"; const DefaultLayout: Component = (props) => { const navigate = useNavigate(); - const isLoggedIn = createAsync(() => getIsLoggedIn()) + const [isLoggedIn] = createResource(async () => { + console.log((await fetch(`${CUB_HOSTNAME}/auth/callback`)).body); - return
-
- navigate("/")} src="/harmonychat_logo.svg" alt="harmony logo" class={styles.logo} /> - { - isLoggedIn() - ? <> - { navigate("/app") }} leadingIcon={OPEN_IN_NEW_ICON}>Open App - { navigate("/oauth/logout") }}>Logout + return false; + }); + + return ( +
+
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} + navigate("/")} src="/harmonychat_logo.svg" alt="harmony logo" class={styles.logo} /> + {isLoggedIn() ? ( + <> + { + navigate("/app"); + }} + leadingIcon={OPEN_IN_NEW_ICON} + > + Open App + + { + navigate("/oauth/logout"); + }} + > + Logout + - : { navigate("/login") }} leadingIcon={ACCOUNT_CIRCLE_ICON}>Login - } + ) : ( + { + navigate("/login"); + }} + leadingIcon={ACCOUNT_CIRCLE_ICON} + > + Login + + )} +
+ {props.children}
- {props.children} -
-} + ); +}; -export default DefaultLayout +export default DefaultLayout; diff --git a/frontend/src/routes/(default)/oauth/error/:errorMessage.module.scss b/frontend/src/routes/(default)/oauth/error/:errorMessage.module.scss deleted file mode 100644 --- a/frontend/src/routes/(default)/oauth/error/:errorMessage.module.scss +++ /dev/null @@ -1,29 +0,0 @@ -.background { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - position: relative; - overflow: hidden; - - &::before { - content: ""; - position: absolute; - width: 700%; - height: 700%; - top: -300%; - left: -300%; - background-image: url(/harmonychat_sparse_background_tile.png); - background-position: center; - rotate: -45deg; - opacity: .1; - } -} - -.card { - display: flex; - flex-direction: column; - width: max-content; - gap: 0.5rem; -} \ No newline at end of file diff --git a/frontend/src/routes/(default)/oauth/error/:errorMessage.tsx b/frontend/src/routes/(default)/oauth/error/:errorMessage.tsx deleted file mode 100644 --- a/frontend/src/routes/(default)/oauth/error/:errorMessage.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import UKButton from "@ewsgit/uikit-solid/src/components/button/UKButton.jsx"; -import UKButtonGroup from "@ewsgit/uikit-solid/src/components/buttonGroup/UKButtonGroup.jsx"; -import UKCard from "@ewsgit/uikit-solid/src/components/card/UKCard.jsx"; -import UKDivider from "@ewsgit/uikit-solid/src/components/divider/UKDivider.jsx"; -import UKText from "@ewsgit/uikit-solid/src/components/text/UKText.jsx"; -import { Title } from "@solidjs/meta"; -import { Component } from "solid-js"; -import styles from "./:errorMessage.module.scss" -import { useNavigate } from "@solidjs/router"; - -const OAuthErrorPage: Component = () => { - const navigate = useNavigate() - - return
- OAuth Error - - An Error Has Occurred - - Oops! looks like we failed to log you in.
perhaps try again later?
- - navigate("/login")}>Retry - navigate("/")}>Go Home - -
-
-} - -export default OAuthErrorPage diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -50,7 +50,7 @@ Continue
Use your AT Protocol handle to log in. If you're unsure,
this is the same as your Bluesky username (by defualt ending in .bsky.social).
Don't have an AT Protocol handle? - { window.location.href = "https://bsky.social/" }}>Sign up to Bluesky. + { window.location.href = "https://bsky.app/" }}>Sign up to Bluesky. ) } diff --git a/frontend/src/routes/oauth/callback.tsx b/frontend/src/routes/oauth/callback.tsx --- a/frontend/src/routes/oauth/callback.tsx +++ b/frontend/src/routes/oauth/callback.tsx @@ -1,5 +1,4 @@ import type { APIEvent } from "@solidjs/start/server"; -import { getOAuthClient, globalAuth } from "~/lib/authentication"; export async function GET({ request }: APIEvent) { try { diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ }, "keywords": [], "author": "", "license": "ISC", + "dependencies": { + "biome": "^0.3.3" + }, "devEngines": { "packageManager": { "name": "pnpm", @@ -18,4 +21,4 @@ "onFail": "download" } }, "type": "module" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,7 +205,11 @@ excludeLinksFromLockfile: false importers: - .: {} + .: + dependencies: + biome: + specifier: ^0.3.3 + version: 0.3.3 centralUserBackend: dependencies: