fix(web): adopt OIDC session from PhotoPrism's localStorage (not cookies)
PhotoPrism's /api/v1/oidc/redirect handler doesn't actually set
auth_token/auth_session cookies — it returns an HTML page that does:
setItem("pp:<storageNamespace>:session.id", <session uid>)
setItem("pp:<storageNamespace>:session.token", <X-Auth-Token value>)
setItem("pp:<storageNamespace>:session.user", <user JSON>)
setItem("pp:<storageNamespace>:session.provider", "oidc")
window.location.href = "/library/login";
The deployment's reverse proxy is expected to bounce /library/login
(and /library/*) back to `/`; the SPA then reads PhotoPrism's
storageNamespace from /api/v1/config, looks up session.id and
session.token under that prefix, and adopts the session.
Confirmed via the M0 test instance: prior to this change, server-side
sessions were created on every OIDC return (DB row present) but the
browser had no way to claim them, so the user bounced back to /login.
This commit is contained in:
@@ -68,28 +68,51 @@ export async function fetchSession(id: string): Promise<PpSessionResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After OIDC completes, PhotoPrism redirects to `siteUrl` with two cookies
|
* After OIDC completes, PhotoPrism returns an HTML page that writes the
|
||||||
* set: `auth_token` (the X-Auth-Token value) and `auth_session` (the session
|
* issued session into `localStorage` under the namespaced keys
|
||||||
* UID). If both are present, fetch the matching session and adopt it so the
|
* `pp:<storageNamespace>:session.{id,token,user,provider}` and then runs
|
||||||
* SPA picks up the OIDC-issued identity without a username/password trip.
|
* `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").
|
* (caller treats null as "stay on /login").
|
||||||
*/
|
*/
|
||||||
export async function bootstrapSessionFromCookies(): Promise<PpSessionResponse | null> {
|
export async function bootstrapSessionFromPhotoPrism(): Promise<PpSessionResponse | null> {
|
||||||
if (!browser) return null;
|
if (!browser) return null;
|
||||||
const read = (name: string): string | null => {
|
// PhotoPrism's storageNamespace is per-instance (build-time hash); fetch
|
||||||
const m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]+)'));
|
// it via /api/v1/config so we resolve the right key prefix.
|
||||||
return m ? decodeURIComponent(m[1]) : null;
|
let namespace: string | undefined;
|
||||||
};
|
try {
|
||||||
const token = read('auth_token');
|
const cfg = await getConfig();
|
||||||
const sid = read('auth_session');
|
namespace = cfg.storageNamespace;
|
||||||
if (!token || !sid) return null;
|
} catch {
|
||||||
// Prime the http client so the X-Auth-Token interceptor fires.
|
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;
|
session.accessToken = token;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchSession(sid);
|
const resp = await fetchSession(sid);
|
||||||
adoptSession(resp);
|
adoptSession(resp);
|
||||||
|
// adoptSession persists into our own storage key (`pp_session`);
|
||||||
|
// PhotoPrism's `pp:<ns>: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;
|
return resp;
|
||||||
} catch {
|
} catch {
|
||||||
session.accessToken = null;
|
session.accessToken = null;
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export interface PpClientConfig {
|
|||||||
previewToken: string;
|
previewToken: string;
|
||||||
downloadToken: string;
|
downloadToken: string;
|
||||||
flags?: string;
|
flags?: string;
|
||||||
|
/**
|
||||||
|
* Per-instance hash that PhotoPrism uses to namespace its own
|
||||||
|
* `localStorage` entries (e.g. `pp:<namespace>: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
|
* Precomputed library counters. PhotoPrism updates these incrementally
|
||||||
* on every mutation, so they're cheap to read and accurate without a
|
* on every mutation, so they're cheap to read and accurate without a
|
||||||
|
|||||||
@@ -19,21 +19,22 @@
|
|||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
// Bootstrap state: the OIDC return drops the user back on `/` with
|
// Bootstrap state: the OIDC return drops the user back on `/` with
|
||||||
// PhotoPrism's auth_token/auth_session cookies set, but the SPA store
|
// PhotoPrism's session info written to localStorage under
|
||||||
// is empty. We try to adopt the cookie session on first mount before
|
// `pp:<storageNamespace>:session.*`, but the SPA store is empty. We
|
||||||
// the auth guard can punt to /login.
|
// try to adopt that session on first mount before the auth guard
|
||||||
|
// can punt to /login.
|
||||||
let bootstrapped = $state(false);
|
let bootstrapped = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!isAuthenticated()) {
|
if (!isAuthenticated()) {
|
||||||
await bootstrapSessionFromCookies();
|
await bootstrapSessionFromPhotoPrism();
|
||||||
}
|
}
|
||||||
bootstrapped = true;
|
bootstrapped = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth guard. Anything outside /login requires a session; otherwise
|
// Auth guard. Anything outside /login requires a session; otherwise
|
||||||
// punt to the login page (which itself redirects authenticated users
|
// 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(() => {
|
$effect(() => {
|
||||||
if (!browser || !bootstrapped) return;
|
if (!browser || !bootstrapped) return;
|
||||||
const onLogin = page.url.pathname === '/login';
|
const onLogin = page.url.pathname === '/login';
|
||||||
|
|||||||
Reference in New Issue
Block a user