Adds an AppPasswords feature flag (default on) so app passwords can be disabled, closing the bypass where an app password minted by an OIDC-provisioned user keeps working after the account expires. When off, minting (oauth/token and auth add) is rejected and existing app passwords are no longer accepted on the REST API (403) or WebDAV (401). App passwords are identified by their session auth provider (IsApplication), covering every grant type. The Apps and Devices affordance is hidden for sessions that may not change a password; operators can force it off via PHOTOPRISM_DISABLE_FEATURES.
The relying-party state and PKCE cookies were left at the zitadel default Path=/, so on a shared-domain proxy (instances served under /i/<tenant>) they reached the callback only if the proxy rewrote the Set-Cookie path. They are now scoped to <base>/api/v1/oidc, covering both the login leg and the redirect callback, so they survive to the callback regardless of any proxy rewrite.
A user who signed in via cluster OIDC now returns to the Portal login (re-auth via OIDC) on sign-out instead of the instance's local form. The /logout route awaits the cluster-wide sign-out so the Portal OP cookie is cleared before the redirect, avoiding a silent re-SSO.
The OIDC RP callback handler returned without writing a response when
CodeExchangeUserInfo failed (e.g. a missing/expired state cookie), leaving
the raw zitadel error ("failed to get state: http: named cookie not present")
on screen with no way forward. CodeExchangeUserInfo now runs the exchange
against a recorder so the handler's raw error never reaches the browser, and
the callback renders the branded auth.gohtml page (which returns the user to
login) instead of a dead-end. Set-Cookie headers from a successful exchange
are still propagated.
- /logout route guard now signs out cluster-wide: session.signOut shares
revokePeerSessions with logoutEverywhere, so a direct /logout revokes peer
sessions and clears shared storage too, not just the current session.
- Decouple PHOTOPRISM_CLUSTER_OIDC from the auto-join/theme toggles: InitConfig
now always runs resolveNodeOIDCClient for an instance (join/theme moved into
bootstrapClusterNode), so a registered node re-wires its OIDC RP on restart
even with both bootstrap toggles disabled.
- Map redirected OAuth server_error / temporarily_unavailable to ErrUnexpected
instead of "Invalid credentials", so a Portal-side failure isn't shown as a
credential problem.
logoutEverywhere read and cleared peer sessions through this.localStorage /
this.sessionStorage, which are NamespacedStorage wrappers (getAppStorage).
Enumerating or clearing a cross-namespace key through a wrapper double-prefixed
it (pp🅰️pp🅱️...), so peer session.token keys were left behind after Sign Out
even though the server sessions were revoked. Unwrap to the raw underlying store
first. The vitest case now constructs the session with a NamespacedStorage, as
the app does, so it would catch the regression.
Implements three OIDC follow-ups from specs/portal/cluster-oidc.md:
- Zero-touch instance OIDC RP config: PHOTOPRISM_CLUSTER_OIDC derives the
OIDC RP client_id/secret from the node client credentials and defaults
the issuer to the instance's own origin (scheme.OriginURL), so a single
boot reaches a working Portal login. Explicit oidc-client/secret/uri win.
- Human-friendly authorize errors: redirectable errors 302 back to the
validated redirect_uri; non-redirectable ones content-negotiate a
branded HTML page (oauth-error.gohtml) vs JSON via shared helpers in
internal/api/oauth_error.go. The RP callback surfaces inbound ?error=.
- Cluster-wide Sign-Out (Tier 2): session.logoutEverywhere fans out a
best-effort DELETE per peer session, clears their storage, then signs
out locally; session-delete clears the OP cookie on any shared-domain
OIDC-against-Portal node (OIDCSessionCookieClearPath).
Show the instance app icon and base path in the switcher, persisting the icon
alongside the SiteUrl and title per namespace (default to the logo when none is
set). Add a Manage Account item gated on the account feature, and open the menu
on hover like the other action menus.
Move component/navigation/user-menu.vue to component/auth/menu.vue (and the
matching Vitest), and add an mdi-dots-horizontal affordance icon to the user
info row so the avatar overlay menu is discoverable.
Replaces mdi-dots-vertical with mdi-dots-horizontal for the navigation
mobile menu trigger and the p-action-menu default icon, and rotates the
lightbox PhotoSwipe menu-button SVG to match (kept centered in its 16x16
viewBox).
showAccountSettings moved out of navigation.vue when the instance switcher
became the shared user menu; account settings is now a declarative sidebar
link. The avatar menu stays covered by user-menu.test.js.
These pages only exist as Plus/Pro/Portal overlays; in editions without
the overlay they now redirect to the user's default route via
$session.getDefaultRoute() instead of rendering a placeholder.
Add the per-file alias so portal vitest resolves the shared common/user-format formatters to the portal overlay, matching the existing common/instance-grants entry.
Skip user-less sessions whose signal NoUser() rejects, warn on the ephemeral key fallback, and keep a manager's delete-by-ref-id from wiping its own OP cookie.
Thread admin authorization into SaveForm via byAdmin so the user-less cluster service principal can persist login/role/WebDAV instead of silently dropping them.
Honor the pp_role claim (gated by pp_issuer_kind) over the group mapping so an instance login reflects the role the Portal granted, restricted to federatable roles.
The explicit overlay z-index already keeps the teleported menu above the sidebar,
so anchor it back to location=top (above the avatar) instead of opening it to the
right of the navigation.
The menu content teleports to body, so a nav-scoped z-index does not apply. Set
an explicit overlay z-index and open it beside the drawer (location=end) so it is
no longer partially covered by the navigation.
Add a shared component/navigation/user-menu.vue (Settings / Switch Instance /
Log Out) wired into the navigation avatar, so every edition gets the switcher.
Instances record their identity under their storage namespace at session auth and
clear it on logout (common/instances + session.js), and the menu lists same-origin
peers that hold a live session. Keeps the distinctive base-path instanceLabel and
the post-login redirect loop guard. Replaces the bootstrap-time directory recording.
The OP session cookie stored the raw bearer token with a full-session lifetime,
so a leak of the cookie value was a usable credential against the whole API. It
now carries an HMAC-signed reference to the session id (a hash of the token,
useless as a bearer) with a 10-minute TTL refreshed on each session GET. The key
is persisted under the Portal config keys directory. The authorize handler
resolves the session via OIDCSessionCookieSession. Needs a coordinated SSO e2e
round in the Portal test env before merge.
Lets the Portal admin/users.vue page (which keeps the common/instance-grants
overlay) be imported under the portal vitest config so its methods can be
unit-tested.
Gate the OIDC relying-party cookie handler's WithUnsecure() on the existing
insecure flag instead of applying it unconditionally, so the round-trip state
(CSRF defense) and PKCE code_verifier cookies retain the Secure attribute on
HTTPS deployments. Addresses audit finding OJW-260603-02 (GO-HTTP-005).
Move the Portal OIDC OP authorize/token/userinfo onto the shared CE
/api/v1/oauth/* routes (the reserved #4368/#4369 stubs) and drop the parallel
root /oauth/* set.
- Add overridable handler hooks (oauth_handlers.go) mirroring
server.WebDAVHandler; OAuthToken delegates only the authorization_code grant
(gated on a form content type) and keeps client_credentials/password/session
unchanged; userinfo now serves GET and POST.
- Scope the OP session cookie to /api/v1/oauth (base-path aware) so the browser
still sends it to the moved authorize endpoint instead of looping.
- Stop the proxy re-scoping instance->Portal authorize redirects
(portalRootPathPrefixes -> /api/v1/oauth/).
- Point discovery at issuer + /api/v1/oauth/*; fix the JWKS URL base-path
doubling for sub-path deployments (also in CE openid.go).