feat(sso): issue one-time tokens for iframe apps
Generate short-lived SSO tokens in the site iframe route and add an exchange API to let child apps bootstrap their PocketBase session.
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
/.output
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* In-memory store for one-time SSO tokens.
|
||||||
|
* Token → { auth, expiresAt }. Exchanged once then deleted.
|
||||||
|
*/
|
||||||
|
const TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
|
type AuthPayload = { token: string; model: Record<string, unknown> | null };
|
||||||
|
|
||||||
|
const store = new Map<string, { auth: AuthPayload; expiresAt: number }>();
|
||||||
|
|
||||||
|
function prune(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of store.entries()) {
|
||||||
|
if (entry.expiresAt <= now) store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set(token: string, auth: AuthPayload): void {
|
||||||
|
prune();
|
||||||
|
store.set(token, { auth, expiresAt: Date.now() + TTL_MS });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get(token: string): AuthPayload | null {
|
||||||
|
const entry = store.get(token);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
store.delete(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
store.delete(token); // one-time use
|
||||||
|
return entry.auth;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { sites, type SiteSlug } from '$lib/sites';
|
import { sites, type SiteSlug } from '$lib/sites';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
import * as ssoStore from '$lib/sso-store';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
export function load({ params }) {
|
export function load({ params, locals }) {
|
||||||
const slug = params.site?.toLowerCase() as SiteSlug | undefined;
|
const slug = params.site?.toLowerCase() as SiteSlug | undefined;
|
||||||
const site = slug && slug in sites ? sites[slug as SiteSlug] : null;
|
const site = slug && slug in sites ? sites[slug as SiteSlug] : null;
|
||||||
|
|
||||||
@@ -9,9 +11,22 @@ export function load({ params }) {
|
|||||||
throw error(404, `Unknown site: ${params.site}`);
|
throw error(404, `Unknown site: ${params.site}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ssoToken: string | undefined;
|
||||||
|
if (locals.user && locals.pb?.authStore?.token) {
|
||||||
|
try {
|
||||||
|
const token = locals.pb.authStore.token;
|
||||||
|
const model = locals.pb.authStore.record as Record<string, unknown> | null;
|
||||||
|
ssoToken = randomUUID();
|
||||||
|
ssoStore.set(ssoToken, { token, model });
|
||||||
|
} catch {
|
||||||
|
// no sso if export fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slug: slug as SiteSlug,
|
slug: slug as SiteSlug,
|
||||||
url: site.url,
|
url: site.url,
|
||||||
title: site.title
|
title: site.title,
|
||||||
|
ssoToken
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
let { data }: {
|
let { data }: {
|
||||||
data: { slug: string; url: string; title: string };
|
data: { slug: string; url: string; title: string; ssoToken?: string };
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const iframeSrc = $derived(
|
||||||
|
data.ssoToken
|
||||||
|
? `${data.url}${data.url.includes('?') ? '&' : '?'}sso=${data.ssoToken}`
|
||||||
|
: data.url
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -14,7 +20,7 @@
|
|||||||
<iframe
|
<iframe
|
||||||
id="app-iframe"
|
id="app-iframe"
|
||||||
title={data.title}
|
title={data.title}
|
||||||
src={data.url}
|
src={iframeSrc}
|
||||||
class="absolute inset-0 w-full h-full border-0 rounded-none"
|
class="absolute inset-0 w-full h-full border-0 rounded-none"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import * as ssoStore from '$lib/sso-store';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
return json({ error: 'Missing token' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = ssoStore.get(token);
|
||||||
|
if (!auth) {
|
||||||
|
return json({ error: 'Invalid or expired token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ token: auth.token, model: auth.model });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user