diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts index 4800ef1..83c4b88 100644 --- a/web/src/lib/services/photoprism.ts +++ b/web/src/lib/services/photoprism.ts @@ -68,28 +68,51 @@ export async function fetchSession(id: string): Promise { } /** - * After OIDC completes, PhotoPrism redirects to `siteUrl` with two cookies - * set: `auth_token` (the X-Auth-Token value) and `auth_session` (the session - * UID). If both are present, fetch the matching session and adopt it so the - * SPA picks up the OIDC-issued identity without a username/password trip. + * After OIDC completes, PhotoPrism returns an HTML page that writes the + * issued session into `localStorage` under the namespaced keys + * `pp::session.{id,token,user,provider}` and then runs + * `window.location.href = "/library/login"`. With our Caddy bouncing + * `/library/*` back to `/`, the browser lands on the SvelteKit root with + * those entries already in localStorage but with no PhotoPrism cookies set + * — so we read them back to adopt the OIDC-issued session. * - * Returns the adopted session, or null when the cookies are missing or stale + * Returns the adopted session, or null when nothing is waiting in storage * (caller treats null as "stay on /login"). */ -export async function bootstrapSessionFromCookies(): Promise { +export async function bootstrapSessionFromPhotoPrism(): Promise { if (!browser) return null; - const read = (name: string): string | null => { - const m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]+)')); - return m ? decodeURIComponent(m[1]) : null; - }; - const token = read('auth_token'); - const sid = read('auth_session'); - if (!token || !sid) return null; - // Prime the http client so the X-Auth-Token interceptor fires. + // PhotoPrism's storageNamespace is per-instance (build-time hash); fetch + // it via /api/v1/config so we resolve the right key prefix. + let namespace: string | undefined; + try { + const cfg = await getConfig(); + namespace = cfg.storageNamespace; + } catch { + return null; + } + if (!namespace) return null; + const prefix = `pp:${namespace}:`; + const sid = localStorage.getItem(prefix + 'session.id'); + const token = localStorage.getItem(prefix + 'session.token'); + if (!sid || !token) return null; + // Prime the http client so the X-Auth-Token interceptor fires for the + // session lookup below. session.accessToken = token; try { const resp = await fetchSession(sid); adoptSession(resp); + // adoptSession persists into our own storage key (`pp_session`); + // PhotoPrism's `pp::session.*` entries are one-shot delivery, + // so clear them now to avoid stale state on logout. + for (const k of [ + 'session.id', + 'session.token', + 'session.user', + 'session.provider', + 'session.error' + ]) { + localStorage.removeItem(prefix + k); + } return resp; } catch { session.accessToken = null; diff --git a/web/src/lib/types/photoprism.ts b/web/src/lib/types/photoprism.ts index c30a398..16875a2 100644 --- a/web/src/lib/types/photoprism.ts +++ b/web/src/lib/types/photoprism.ts @@ -23,6 +23,13 @@ export interface PpClientConfig { previewToken: string; downloadToken: string; flags?: string; + /** + * Per-instance hash that PhotoPrism uses to namespace its own + * `localStorage` entries (e.g. `pp::session.token`). The + * OIDC redirect HTML drops the issued session under this prefix; we + * read it back to adopt the SSO identity into the SPA store. + */ + storageNamespace?: string; /** * Precomputed library counters. PhotoPrism updates these incrementally * on every mutation, so they're cheap to read and accurate without a diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 086d220..a7ea595 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -19,21 +19,22 @@ let { children } = $props(); // Bootstrap state: the OIDC return drops the user back on `/` with - // PhotoPrism's auth_token/auth_session cookies set, but the SPA store - // is empty. We try to adopt the cookie session on first mount before - // the auth guard can punt to /login. + // PhotoPrism's session info written to localStorage under + // `pp::session.*`, but the SPA store is empty. We + // try to adopt that session on first mount before the auth guard + // can punt to /login. let bootstrapped = $state(false); onMount(async () => { if (!isAuthenticated()) { - await bootstrapSessionFromCookies(); + await bootstrapSessionFromPhotoPrism(); } bootstrapped = true; }); // Auth guard. Anything outside /login requires a session; otherwise // punt to the login page (which itself redirects authenticated users - // back to /). Held until the cookie bootstrap has had a chance to run. + // back to /). Held until the bootstrap pass has had a chance to run. $effect(() => { if (!browser || !bootstrapped) return; const onLogin = page.url.pathname === '/login';