Skip to content
Draft
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
8 changes: 8 additions & 0 deletions webui/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ SIMPLYPRINT_CLIENT_SECRET=
# SimplyPrint and causes the token exchange to fail (exchange_failed).
SIMPLYPRINT_REDIRECT_URI=https://openfilamentdatabase.org/api/auth/simplyprint/callback

# === Embedded (SimplyPrint panel) OAuth ===
# When OFD is embedded in the SimplyPrint panel, providers (SimplyPrint/GitHub)
# can't be framed, so login runs in a popup and the tokens are sealed (AES-GCM)
# for the popup->frame handoff. Set a long random secret to key that sealing.
# Optional: falls back to SIMPLYPRINT_CLIENT_SECRET / PUBLIC_SIMPLYPRINT_CLIENT_ID
# if unset, but a dedicated secret is recommended in production.
OFD_EMBED_SEAL_SECRET=

# === Bot Submissions (via GitHub App) ===
# Enable bot PR creation for SimplyPrint-authenticated users
ANON_BOT_ENABLED=false
Expand Down
35 changes: 35 additions & 0 deletions webui/src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { copyFileSync, existsSync } from 'fs';
import { join } from 'path';
import { env } from '$env/dynamic/public';
import type { Handle } from '@sveltejs/kit';
import { installServerLogCapture } from '$lib/server/debugLog';

// Install server-side log capture as early as possible so all output is buffered
Expand Down Expand Up @@ -94,3 +95,37 @@ if (env.PUBLIC_API_BASE_URL) {
if (process.env.ANON_BOT_ENABLED === 'true') {
console.log(`[env] Bot submissions: enabled`);
}

// Hosts allowed to embed the app in an iframe (the SimplyPrint panel modal).
// Extra origins can be added via EMBED_FRAME_ANCESTORS (space-separated).
const FRAME_ANCESTORS = [
"'self'",
'https://simplyprint.io',
'https://*.simplyprint.io',
...(process.env.EMBED_FRAME_ANCESTORS?.trim().split(/\s+/).filter(Boolean) ?? [])
].join(' ');

/**
* Allow the app to be framed by SimplyPrint (and drop any restrictive
* X-Frame-Options a proxy might inject). `frame-ancestors` is the modern,
* origin-scoped control that browsers honour for embedding.
*/
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);

// Only relax framing for document responses; leave asset/data responses alone.
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('text/html')) {
response.headers.delete('X-Frame-Options');
const existing = response.headers.get('Content-Security-Policy');
const frameDirective = `frame-ancestors ${FRAME_ANCESTORS}`;
response.headers.set(
'Content-Security-Policy',
existing && !/frame-ancestors/i.test(existing)
? `${existing}; ${frameDirective}`
: frameDirective
);
}

return response;
};
12 changes: 10 additions & 2 deletions webui/src/lib/components/layout/ChangesMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import { authStore } from '$lib/stores/auth';
import { userPrefs } from '$lib/stores/userPrefs';
import { STORAGE_KEY_REOPEN_WIZARD } from '$lib/config/storageKeys';
import { getEmbedState } from '$lib/stores/embed';
import { postToHost } from '$lib/services/embedBridge';
import { onMount, onDestroy } from 'svelte';

let menuOpen = $state(false);
Expand Down Expand Up @@ -440,7 +442,9 @@
body: JSON.stringify({
changes: exportData.changes,
images: imagesWithPaths,
title: generateChangeTitle(exportData.changes)
title: generateChangeTitle(exportData.changes),
// Attribute embedded submissions to the host (e.g. "via SimplyPrint").
wrapper: getEmbedState().wrapper || undefined
})
});

Expand All @@ -455,6 +459,7 @@
});
userPrefs.addSubmission(result.uuid!, result.prUrl || '', result.prNumber || 0);
changeStore.clear();
postToHost({ type: 'ofd:submitted', prUrl: result.prUrl, prNumber: result.prNumber });
return {
success: true,
message: 'Your changes have been submitted for review by a maintainer.',
Expand Down Expand Up @@ -492,7 +497,9 @@
changes: exportData.changes,
images: imagesWithPaths,
title: title || generateChangeTitle(exportData.changes),
description
description,
// Attribute embedded submissions to the host (e.g. "via SimplyPrint").
wrapper: getEmbedState().wrapper || undefined
})
});

Expand All @@ -508,6 +515,7 @@
});
userPrefs.addSubmission(uuid, result.prUrl || '', result.prNumber || 0);
changeStore.clear();
postToHost({ type: 'ofd:submitted', prUrl: result.prUrl, prNumber: result.prNumber });
return {
success: true,
message: `PR #${result.prNumber} created successfully!`,
Expand Down
4 changes: 3 additions & 1 deletion webui/src/lib/server/anonBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface AnonSubmission {
images: Record<string, any>;
title?: string;
description?: string;
/** Resolved attribution label (e.g. "SimplyPrint") for embedded submissions. */
wrapper?: string;
}

export interface AnonSubmissionResult {
Expand Down Expand Up @@ -107,7 +109,7 @@ export async function createAnonPR(submission: AnonSubmission): Promise<AnonSubm
const uuidComment = buildUuidComment(submission.uuid);
const changesSummary = buildChangesSummary(submission.changes);

const via = publicEnv.PUBLIC_WRAPPER_NAME || 'the OFD web editor';
const via = submission.wrapper || publicEnv.PUBLIC_WRAPPER_NAME || 'the OFD web editor';
const attribution = `*Submitted via ${via}*`;

const prBody = [
Expand Down
105 changes: 92 additions & 13 deletions webui/src/lib/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,48 @@ import { env } from '$env/dynamic/private';

const GH_COOKIE = 'ofd_gh_token';
const SP_COOKIE = 'ofd_sp_token';
const SP_REFRESH_COOKIE = 'ofd_sp_refresh';

const COOKIE_OPTIONS = {
path: '/',
httpOnly: true,
secure: !dev,
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24 * 30 // 30 days
};
const THIRTY_DAYS = 60 * 60 * 24 * 30;
const ONE_YEAR = 60 * 60 * 24 * 365;

/**
* Cookie attributes. When the app runs inside a cross-site iframe (the
* SimplyPrint panel modal), browsers will NOT send SameSite=Lax cookies, so the
* OAuth handshake + session must use SameSite=None; Secure; Partitioned (CHIPS).
* SameSite=None requires Secure — localhost is a secure context in Chrome, so
* embedded cookies are always marked Secure (works over http://localhost too).
*/
function cookieOptions(embedded = false, maxAge = THIRTY_DAYS) {
return {
path: '/',
httpOnly: true,
secure: embedded ? true : !dev,
sameSite: (embedded ? 'none' : 'lax') as 'none' | 'lax',
...(embedded ? { partitioned: true } : {}),
maxAge
};
}

/** Delete a cookie in both partitioned and unpartitioned jars. */
function clearCookie(cookies: Cookies, name: string): void {
cookies.delete(name, { path: '/' });
// Partitioned cookies live in a separate jar keyed by the top-level site.
cookies.delete(name, { path: '/', partitioned: true } as Parameters<Cookies['delete']>[1]);
}

// --- GitHub ---

export function getGitHubToken(cookies: Cookies): string | undefined {
return cookies.get(GH_COOKIE);
}

export function setGitHubToken(cookies: Cookies, token: string): void {
cookies.set(GH_COOKIE, token, COOKIE_OPTIONS);
export function setGitHubToken(cookies: Cookies, token: string, embedded = false): void {
cookies.set(GH_COOKIE, token, cookieOptions(embedded));
}

export function clearGitHubToken(cookies: Cookies): void {
cookies.delete(GH_COOKIE, { path: '/' });
clearCookie(cookies, GH_COOKIE);
}

export async function exchangeCodeForToken(
Expand Down Expand Up @@ -103,12 +124,27 @@ export function getSimplyPrintToken(cookies: Cookies): string | undefined {
return cookies.get(SP_COOKIE);
}

export function setSimplyPrintToken(cookies: Cookies, token: string): void {
cookies.set(SP_COOKIE, token, { ...COOKIE_OPTIONS, maxAge: 3600 });
/** Store the short-lived access token (1h — matches SimplyPrint token lifetime). */
export function setSimplyPrintToken(cookies: Cookies, token: string, embedded = false): void {
cookies.set(SP_COOKIE, token, cookieOptions(embedded, 3600));
}

export function getSimplyPrintRefreshToken(cookies: Cookies): string | undefined {
return cookies.get(SP_REFRESH_COOKIE);
}

/**
* Store the long-lived refresh token (SimplyPrint refresh tokens last ~1 year)
* so an expired access token can be renewed silently — no re-consent, which is
* what makes the embedded "one-time consent" login feel seamless on later opens.
*/
export function setSimplyPrintRefreshToken(cookies: Cookies, token: string, embedded = false): void {
cookies.set(SP_REFRESH_COOKIE, token, cookieOptions(embedded, ONE_YEAR));
}

export function clearSimplyPrintToken(cookies: Cookies): void {
cookies.delete(SP_COOKIE, { path: '/' });
clearCookie(cookies, SP_COOKIE);
clearCookie(cookies, SP_REFRESH_COOKIE);
}

export async function exchangeSimplyPrintCode(
Expand Down Expand Up @@ -162,6 +198,49 @@ export async function exchangeSimplyPrintCode(
return data;
}

/**
* Exchange a refresh token for a fresh access token (refresh_token grant).
* Used to silently renew an expired session without re-prompting for consent.
*/
export async function refreshSimplyPrintToken(
refreshToken: string,
clientId: string,
clientSecret?: string
): Promise<{ access_token: string; refresh_token?: string }> {
clientId = clientId.trim();
clientSecret = clientSecret?.trim();

const form = new URLSearchParams({
grant_type: 'refresh_token',
client_id: clientId,
refresh_token: refreshToken
});
if (clientSecret) form.set('client_secret', clientSecret);

const response = await fetch(`${SP_API_BASE}/oauth2/Token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form
});

if (!response.ok) {
const body = await response.text();
console.error('[SP OAuth] Refresh endpoint error:', response.status, body, {
client_id: clientId,
has_client_secret: !!clientSecret
});
throw new Error('SimplyPrint token refresh failed: ' + response.status);
}

const data = await response.json();
if (data.error) {
console.error('[SP OAuth] Refresh returned error:', data);
throw new Error(`SimplyPrint refresh error: ${data.error_description || data.error}`);
}

return data;
}

export interface SimplyPrintUser {
id: number;
name: string;
Expand Down
68 changes: 68 additions & 0 deletions webui/src/lib/server/popupHandoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Renders the final page shown in the OAuth *popup* (embedded-mode login).
*
* The popup completed the provider round-trip in its own top-level (first-party
* OFD) context. It now hands the outcome back to the opener — the OFD iframe
* inside the SimplyPrint panel — over postMessage, then closes itself. The
* iframe adopts the sealed tokens into its own cookie partition (see the
* matching `/adopt` route). On failure it relays an error so the frame can
* surface it instead of hanging.
*
* Only the sealed (opaque) blob crosses the postMessage boundary; the raw tokens
* never touch page scripts. We target the OFD origin explicitly so no other
* frame can read the message.
*/

type Outcome = { sealed: string } | { error: string };

export function popupHandoffPage(
origin: string,
provider: 'simplyprint' | 'github',
outcome: Outcome
): Response {
const message = { type: `ofd:${provider}-auth`, ...outcome };
// origin + message are our own values (origin = this deploy; sealed = base64url;
// error = a fixed slug), but JSON.stringify keeps the inline script well-formed.
const payload = JSON.stringify(message);
const target = JSON.stringify(origin);
const ok = 'sealed' in outcome;

const html = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Signing in…</title>
<style>
html,body{height:100%;margin:0}
body{display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;
background:#0b0b0c;color:#e5e5e5}
.box{text-align:center;padding:2rem}
.spin{width:28px;height:28px;border:3px solid #333;border-top-color:#4f8cff;border-radius:50%;
margin:0 auto 1rem;animation:s .8s linear infinite}
@keyframes s{to{transform:rotate(360deg)}}
</style></head>
<body>
<div class="box">
<div class="spin"></div>
<p>${ok ? 'Signing you in…' : 'Sign-in failed. You can close this window.'}</p>
</div>
<script>
(function () {
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(${payload}, ${target});
}
} catch (e) { /* opener gone — nothing to hand back */ }
// Give the message a tick to deliver, then close.
setTimeout(function () { try { window.close(); } catch (e) {} }, 150);
})();
</script>
</body>
</html>`;

return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store'
}
});
}
Loading
Loading