mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Sign-out of an OIDC session redirects the browser to the provider's discovered end_session_endpoint (id_token_hint + absolute post_logout_redirect_uri) when PHOTOPRISM_OIDC_LOGOUT is enabled, so the provider SSO session ends and the next login re-prompts. Opt-in (default off); local/LDAP logout and providers without an end-session endpoint fall back to local logout. Adds the shared CE /api/v1/oauth/logout route and OAuthLogoutHandler hook plus the Portal OP end_session_endpoint advertised in the Portal discovery document, for Portal builds to serve via the override hook.
This commit is contained in:
parent
3f096a592b
commit
50976f427e
21 changed files with 576 additions and 10 deletions
|
|
@ -27,6 +27,7 @@ When integrating third-party clients such as native mobile apps that embed the P
|
|||
- In the standard login UI, this preference is exposed through the `Stay signed in on this device` toggle. Checked means persistent namespaced `localStorage`; unchecked means ephemeral namespaced `sessionStorage`.
|
||||
- The login page initializes that toggle from the current session storage mode when available, and otherwise falls back to the stored namespaced `session` preference flag.
|
||||
- Logout and session reset clear the current app namespace from both `localStorage` and `sessionStorage`, and also remove deprecated raw legacy auth keys from both stores, without touching other namespaced instances on the same origin.
|
||||
- When `PHOTOPRISM_OIDC_LOGOUT` is enabled, `DELETE /api/v1/session` returns a `providerLogoutUri`; `logout()` passes it to `onLogout()`, which redirects the browser to the provider's end-session endpoint (RP-initiated logout) instead of the local login page, so the provider's SSO session is ended too. Without it, sign-out lands on the local/Portal login page as before.
|
||||
- Regression tests for session restore should exercise both preferred `sessionStorage` and a real `Config`-shaped object so namespace resolution bugs cannot silently fall back to `pp:root:`.
|
||||
|
||||
Preferred integration contract for new native or web view clients:
|
||||
|
|
|
|||
|
|
@ -823,9 +823,12 @@ export default class Session {
|
|||
return this.config.loginUri;
|
||||
}
|
||||
|
||||
onLogout(noRedirect) {
|
||||
// Capture the redirect target before reset() clears the auth provider.
|
||||
const redirectUri = this.logoutRedirectUri();
|
||||
// onLogout resets client state and redirects to the post-sign-out landing page.
|
||||
onLogout(noRedirect, providerLogoutUri) {
|
||||
// Prefer the provider's RP-initiated logout URL when the backend returned one
|
||||
// (PHOTOPRISM_OIDC_LOGOUT), so the browser ends the provider SSO session; otherwise
|
||||
// capture the local target before reset() clears the auth provider.
|
||||
const redirectUri = providerLogoutUri || this.logoutRedirectUri();
|
||||
|
||||
this.reset();
|
||||
|
||||
|
|
@ -898,8 +901,10 @@ export default class Session {
|
|||
if (this.isAuthenticated()) {
|
||||
return $api
|
||||
.delete("session")
|
||||
.then(() => {
|
||||
return this.onLogout(noRedirect);
|
||||
.then((resp) => {
|
||||
// The backend returns providerLogoutUri when RP-initiated logout is enabled,
|
||||
// so the browser is redirected to the provider's end-session endpoint.
|
||||
return this.onLogout(noRedirect, resp?.data?.providerLogoutUri);
|
||||
})
|
||||
.catch(() => {
|
||||
return this.onLogout(noRedirect);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import "../fixtures";
|
||||
import { $config } from "app/session";
|
||||
import $api from "common/api";
|
||||
import Session from "common/session";
|
||||
import { buildNamespace, createNamespacedStorage } from "common/storage";
|
||||
import StorageShim from "node-storage-shim";
|
||||
|
|
@ -724,6 +725,57 @@ describe("common/session", () => {
|
|||
|
||||
expect(spy).toHaveBeenCalledWith("/library/login");
|
||||
});
|
||||
|
||||
it("prefers a backend-provided provider logout URL over the local target", () => {
|
||||
const rawStorage = new StorageShim();
|
||||
const namespaceKey = "ns-logout-provider-uri";
|
||||
const storage = createNamespacedStorage(rawStorage, namespaceKey);
|
||||
const session = new Session(storage, clusterOidcConfig(namespaceKey, { cluster: false }));
|
||||
session.provider = "oidc";
|
||||
const spy = vi.spyOn(session, "followRedirect").mockImplementation(() => {});
|
||||
|
||||
const providerLogoutUri = "https://keycloak.localssl.dev/realms/master/protocol/openid-connect/logout?id_token_hint=abc";
|
||||
session.onLogout(false, providerLogoutUri);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(providerLogoutUri);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout RP-initiated provider redirect", () => {
|
||||
it("passes providerLogoutUri from the DELETE response to onLogout", async () => {
|
||||
const storage = new StorageShim();
|
||||
const session = new Session(storage, createConfig("/library", "ns-logout-rp"));
|
||||
session.setAuthToken("999900000000000000000000000000000000000000000000");
|
||||
session.applyId("a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f");
|
||||
session.user = new (session.user.constructor)({ ID: 7, Name: "x", DisplayName: "X" });
|
||||
expect(session.isAuthenticated()).toBe(true);
|
||||
|
||||
const providerLogoutUri = "https://keycloak.localssl.dev/realms/master/protocol/openid-connect/logout?id_token_hint=abc";
|
||||
const deleteSpy = vi.spyOn($api, "delete").mockResolvedValue({ data: { status: "deleted", providerLogoutUri } });
|
||||
const onLogoutSpy = vi.spyOn(session, "onLogout").mockReturnValue(Promise.resolve());
|
||||
|
||||
await session.logout();
|
||||
|
||||
expect(deleteSpy).toHaveBeenCalledWith("session");
|
||||
expect(onLogoutSpy).toHaveBeenCalledWith(undefined, providerLogoutUri);
|
||||
deleteSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to local logout when the response carries no provider URL", async () => {
|
||||
const storage = new StorageShim();
|
||||
const session = new Session(storage, createConfig("/library", "ns-logout-rp-none"));
|
||||
session.setAuthToken("999900000000000000000000000000000000000000000000");
|
||||
session.applyId("a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f");
|
||||
session.user = new (session.user.constructor)({ ID: 7, Name: "x", DisplayName: "X" });
|
||||
|
||||
const deleteSpy = vi.spyOn($api, "delete").mockResolvedValue({ data: { status: "deleted" } });
|
||||
const onLogoutSpy = vi.spyOn(session, "onLogout").mockReturnValue(Promise.resolve());
|
||||
|
||||
await session.logout();
|
||||
|
||||
expect(onLogoutSpy).toHaveBeenCalledWith(undefined, undefined);
|
||||
deleteSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signOut", () => {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ var OAuthAuthorizeHandler gin.HandlerFunc = defaultOAuthAuthorize
|
|||
// a 405 placeholder; Portal builds set it to the OIDC OP userinfo handler.
|
||||
var OAuthUserinfoHandler gin.HandlerFunc = defaultOAuthUserinfo
|
||||
|
||||
// OAuthLogoutHandler handles GET and POST /api/v1/oauth/logout (OIDC RP-initiated
|
||||
// logout end-session endpoint). Defaults to a 405 placeholder; Portal builds set it
|
||||
// to the OIDC OP end-session handler.
|
||||
var OAuthLogoutHandler gin.HandlerFunc = defaultOAuthLogout
|
||||
|
||||
// OAuthAuthorizationCodeHandler handles the authorization_code grant on the
|
||||
// shared POST /api/v1/oauth/token endpoint. Nil on CE/Pro builds (the grant is
|
||||
// then reported as unsupported); Portal builds set it to the OIDC OP token
|
||||
|
|
|
|||
64
internal/api/oauth_logout.go
Normal file
64
internal/api/oauth_logout.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/http/header"
|
||||
"github.com/photoprism/photoprism/pkg/i18n"
|
||||
)
|
||||
|
||||
// OAuthLogout registers the OAuth2/OIDC end-session endpoint for RP-initiated logout.
|
||||
// The behavior is provided by OAuthLogoutHandler, which defaults to a placeholder and is
|
||||
// replaced by Portal builds with the OIDC OP end-session handler (see oauth_handlers.go
|
||||
// and portal/internal/server/register.go). Both GET and POST are registered per the
|
||||
// OpenID Connect RP-Initiated Logout specification.
|
||||
//
|
||||
// @Summary OAuth2/OIDC end-session endpoint (RP-initiated logout)
|
||||
// @Id OAuthLogout
|
||||
// @Tags Authentication
|
||||
// @Produce json
|
||||
// @Success 302 {string} string "redirect to post_logout_redirect_uri"
|
||||
// @Failure 403,405 {object} i18n.Response
|
||||
// @Router /api/v1/oauth/logout [get]
|
||||
// @Router /api/v1/oauth/logout [post]
|
||||
func OAuthLogout(router *gin.RouterGroup) {
|
||||
router.GET("/oauth/logout", func(c *gin.Context) {
|
||||
OAuthLogoutHandler(c)
|
||||
})
|
||||
router.POST("/oauth/logout", func(c *gin.Context) {
|
||||
OAuthLogoutHandler(c)
|
||||
})
|
||||
}
|
||||
|
||||
// defaultOAuthLogout is the placeholder end-session handler used unless a Portal build
|
||||
// overrides OAuthLogoutHandler with the OIDC OP end-session handler.
|
||||
func defaultOAuthLogout(c *gin.Context) {
|
||||
// Prevent CDNs from caching this endpoint.
|
||||
if header.IsCdn(c.Request) {
|
||||
AbortNotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Disable caching of responses.
|
||||
c.Header(header.CacheControl, header.CacheControlNoStore)
|
||||
|
||||
// Get client IP address for logs and rate limiting checks.
|
||||
clientIp := ClientIP(c)
|
||||
actor := "unknown client"
|
||||
action := "logout"
|
||||
|
||||
// Abort if running in public mode.
|
||||
if get.Config().Public() {
|
||||
event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.ErrDisabledInPublicMode.Error()})
|
||||
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// The OIDC OP end-session endpoint is only available on Portal builds.
|
||||
c.JSON(http.StatusMethodNotAllowed, gin.H{"status": StatusFailed})
|
||||
}
|
||||
59
internal/api/oauth_logout_test.go
Normal file
59
internal/api/oauth_logout_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
)
|
||||
|
||||
func TestOAuthLogout(t *testing.T) {
|
||||
t.Run("PublicMode", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
|
||||
OAuthLogout(router)
|
||||
|
||||
r := PerformRequest(app, http.MethodGet, "/api/v1/oauth/logout")
|
||||
assert.Equal(t, http.StatusForbidden, r.Code)
|
||||
})
|
||||
t.Run("DefaultNotImplemented", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
OAuthLogout(router)
|
||||
|
||||
r := PerformRequest(app, http.MethodGet, "/api/v1/oauth/logout")
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, r.Code)
|
||||
})
|
||||
t.Run("PostRouteRegistered", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
OAuthLogout(router)
|
||||
|
||||
r := PerformRequest(app, http.MethodPost, "/api/v1/oauth/logout")
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, r.Code)
|
||||
})
|
||||
t.Run("OverrideHookDelegates", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
|
||||
OAuthLogout(router)
|
||||
|
||||
prev := OAuthLogoutHandler
|
||||
OAuthLogoutHandler = func(c *gin.Context) { c.String(http.StatusOK, "delegated:"+c.Request.Method) }
|
||||
defer func() { OAuthLogoutHandler = prev }()
|
||||
|
||||
get := PerformRequest(app, http.MethodGet, "/api/v1/oauth/logout")
|
||||
assert.Equal(t, http.StatusOK, get.Code)
|
||||
assert.Equal(t, "delegated:GET", get.Body.String())
|
||||
|
||||
post := PerformRequest(app, http.MethodPost, "/api/v1/oauth/logout")
|
||||
assert.Equal(t, http.StatusOK, post.Code)
|
||||
assert.Equal(t, "delegated:POST", post.Body.String())
|
||||
})
|
||||
}
|
||||
|
|
@ -2,14 +2,18 @@ package api
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/http/header"
|
||||
"github.com/photoprism/photoprism/pkg/http/scheme"
|
||||
"github.com/photoprism/photoprism/pkg/log/status"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/internal/auth/oidc"
|
||||
"github.com/photoprism/photoprism/internal/auth/session"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
|
|
@ -82,20 +86,73 @@ func DeleteSession(router *gin.RouterGroup) {
|
|||
event.AuditDebug([]string{clientIp, "session %s", "deleted"}, s.RefID)
|
||||
}
|
||||
|
||||
// Clear the OP session cookie on the caller's own logout (not a manager
|
||||
// deleting another session by ref id), so a cluster-wide Sign-Out stops silent
|
||||
// re-SSO no matter which node is hit. OIDCSessionCookieClearPath picks the path.
|
||||
// Confirmation response; gains an optional providerLogoutUri below.
|
||||
resp := DeleteSessionResponse(s.ID)
|
||||
|
||||
// On the caller's own logout (not a manager deleting another session by ref id),
|
||||
// clear the OP session cookie so a cluster-wide Sign-Out stops silent re-SSO no
|
||||
// matter which node is hit, and — when PHOTOPRISM_OIDC_LOGOUT is enabled — return
|
||||
// the provider's RP-initiated logout URL so the browser can end the provider session.
|
||||
if conf := get.Config(); !rnd.IsRefID(id) {
|
||||
if clearPath := OIDCSessionCookieClearPath(conf); clearPath != "" {
|
||||
ClearOIDCSessionCookie(c, clearPath, conf.SiteHttps())
|
||||
}
|
||||
|
||||
if logoutUri := oidcLogoutURL(conf, get.OIDC(), s); logoutUri != "" {
|
||||
resp["providerLogoutUri"] = logoutUri
|
||||
event.AuditInfo([]string{clientIp, "session %s", "oidc provider logout initiated", status.Granted}, s.RefID)
|
||||
}
|
||||
}
|
||||
|
||||
// Return JSON response for confirmation.
|
||||
c.JSON(http.StatusOK, DeleteSessionResponse(s.ID))
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
router.DELETE("/session", deleteSessionHandler)
|
||||
router.DELETE("/session/:id", deleteSessionHandler)
|
||||
router.DELETE("/sessions/:id", deleteSessionHandler)
|
||||
}
|
||||
|
||||
// oidcLogoutURL returns the RP-initiated logout URL for a just-deleted OIDC session, or ""
|
||||
// when RP-initiated logout is disabled, the session was not authenticated via OIDC, or the
|
||||
// provider advertises no end_session_endpoint. The browser is then redirected there so the
|
||||
// provider ends its own SSO session and a subsequent login re-prompts for credentials.
|
||||
func oidcLogoutURL(conf *config.Config, provider *oidc.Client, s *entity.Session) string {
|
||||
if conf == nil || provider == nil || s == nil {
|
||||
return ""
|
||||
} else if !conf.OIDCLogout() || s.IdToken == "" || !s.GetProvider().IsOIDC() {
|
||||
return ""
|
||||
}
|
||||
|
||||
logoutUri, err := provider.EndSessionURL(s.IdToken, AbsoluteLoginURL(conf), "")
|
||||
|
||||
if err != nil {
|
||||
event.AuditWarn([]string{"oidc", "provider logout", status.Error(err)})
|
||||
return ""
|
||||
}
|
||||
|
||||
return logoutUri
|
||||
}
|
||||
|
||||
// AbsoluteLoginURL resolves the node's login page to an absolute URL, so it can be used
|
||||
// as an OIDC post_logout_redirect_uri (which providers require to be absolute and
|
||||
// registered). LoginUri() is a root-relative path; it is joined to the site origin.
|
||||
func AbsoluteLoginURL(conf *config.Config) string {
|
||||
path := conf.LoginUri()
|
||||
|
||||
if strings.Contains(path, "://") {
|
||||
return path
|
||||
}
|
||||
|
||||
origin := scheme.OriginURL(conf.SiteUrl())
|
||||
|
||||
if origin == "" {
|
||||
return path
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
return strings.TrimRight(origin, "/") + path
|
||||
}
|
||||
|
|
|
|||
98
internal/api/session_delete_test.go
Normal file
98
internal/api/session_delete_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/oidc"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
)
|
||||
|
||||
// fakeRelyingParty implements only the rp.RelyingParty methods EndSessionURL reads.
|
||||
type fakeRelyingParty struct {
|
||||
rp.RelyingParty
|
||||
endSession string
|
||||
clientID string
|
||||
}
|
||||
|
||||
func (f fakeRelyingParty) GetEndSessionEndpoint() string { return f.endSession }
|
||||
func (f fakeRelyingParty) OAuthConfig() *oauth2.Config { return &oauth2.Config{ClientID: f.clientID} }
|
||||
|
||||
func oidcProvider(endSession string) *oidc.Client {
|
||||
return &oidc.Client{RelyingParty: fakeRelyingParty{endSession: endSession, clientID: "client-abc"}}
|
||||
}
|
||||
|
||||
func oidcSession(provider authn.ProviderType, idToken string) *entity.Session {
|
||||
s := &entity.Session{}
|
||||
s.SetProvider(provider)
|
||||
s.IdToken = idToken
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOidcLogoutURL(t *testing.T) {
|
||||
conf := config.NewConfig(config.CliTestContext())
|
||||
provider := oidcProvider("https://provider.example/logout")
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
conf.Options().OIDCLogout = true
|
||||
defer func() { conf.Options().OIDCLogout = false }()
|
||||
|
||||
result := oidcLogoutURL(conf, provider, oidcSession(authn.ProviderOIDC, "id-token-123"))
|
||||
require.NotEmpty(t, result)
|
||||
|
||||
u, err := url.Parse(result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "id-token-123", u.Query().Get("id_token_hint"))
|
||||
assert.Equal(t, "client-abc", u.Query().Get("client_id"))
|
||||
// post_logout_redirect_uri must be absolute (providers reject relative paths).
|
||||
plr := u.Query().Get("post_logout_redirect_uri")
|
||||
assert.Equal(t, AbsoluteLoginURL(conf), plr)
|
||||
assert.Contains(t, plr, "://")
|
||||
})
|
||||
t.Run("FlagDisabled", func(t *testing.T) {
|
||||
conf.Options().OIDCLogout = false
|
||||
assert.Equal(t, "", oidcLogoutURL(conf, provider, oidcSession(authn.ProviderOIDC, "id-token-123")))
|
||||
})
|
||||
t.Run("NotOIDCSession", func(t *testing.T) {
|
||||
conf.Options().OIDCLogout = true
|
||||
defer func() { conf.Options().OIDCLogout = false }()
|
||||
assert.Equal(t, "", oidcLogoutURL(conf, provider, oidcSession(authn.ProviderLocal, "id-token-123")))
|
||||
})
|
||||
t.Run("NoIdToken", func(t *testing.T) {
|
||||
conf.Options().OIDCLogout = true
|
||||
defer func() { conf.Options().OIDCLogout = false }()
|
||||
assert.Equal(t, "", oidcLogoutURL(conf, provider, oidcSession(authn.ProviderOIDC, "")))
|
||||
})
|
||||
t.Run("ProviderWithoutEndSession", func(t *testing.T) {
|
||||
conf.Options().OIDCLogout = true
|
||||
defer func() { conf.Options().OIDCLogout = false }()
|
||||
assert.Equal(t, "", oidcLogoutURL(conf, oidcProvider(""), oidcSession(authn.ProviderOIDC, "id-token-123")))
|
||||
})
|
||||
t.Run("NilProvider", func(t *testing.T) {
|
||||
conf.Options().OIDCLogout = true
|
||||
defer func() { conf.Options().OIDCLogout = false }()
|
||||
assert.Equal(t, "", oidcLogoutURL(conf, nil, oidcSession(authn.ProviderOIDC, "id-token-123")))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAbsoluteLoginURL(t *testing.T) {
|
||||
t.Run("RelativeLoginPathResolvedToOrigin", func(t *testing.T) {
|
||||
conf := config.NewConfig(config.CliTestContext())
|
||||
result := AbsoluteLoginURL(conf)
|
||||
assert.Contains(t, result, "://", "must be absolute")
|
||||
assert.True(t, strings.HasSuffix(result, conf.LoginUri()), "must end with the login path")
|
||||
})
|
||||
t.Run("AbsoluteLoginUriPassedThrough", func(t *testing.T) {
|
||||
conf := config.NewConfig(config.CliTestContext())
|
||||
conf.Options().LoginUri = "https://sso.example.com/login"
|
||||
assert.Equal(t, "https://sso.example.com/login", AbsoluteLoginURL(conf))
|
||||
})
|
||||
}
|
||||
|
|
@ -730,6 +730,9 @@
|
|||
"OIDCIcon": {
|
||||
"type": "string"
|
||||
},
|
||||
"OIDCLogout": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"OIDCProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -9402,6 +9405,68 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/oauth/logout": {
|
||||
"get": {
|
||||
"operationId": "OAuthLogout",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"302": {
|
||||
"description": "redirect to post_logout_redirect_uri",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"405": {
|
||||
"description": "Method Not Allowed",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "OAuth2/OIDC end-session endpoint (RP-initiated logout)",
|
||||
"tags": [
|
||||
"Authentication"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "OAuthLogout",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"302": {
|
||||
"description": "redirect to post_logout_redirect_uri",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"405": {
|
||||
"description": "Method Not Allowed",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "OAuth2/OIDC end-session endpoint (RP-initiated logout)",
|
||||
"tags": [
|
||||
"Authentication"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/oauth/revoke": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
|
||||
- `oidc.go` — package doc + logger.
|
||||
- `client.go` — RP construction (`NewClient`), PKCE detection, auth redirect, code exchange + userinfo retrieval.
|
||||
- `logout.go` — `(*Client).EndSessionURL` builds the RP-initiated logout URL (id_token_hint, post_logout_redirect_uri, client_id, state) for a browser redirect; tests in `logout_test.go`.
|
||||
- `http_client.go` — shared HTTP client with TLS toggle and timeouts; helpers for tests in `http_client_test.go`.
|
||||
- `redirect_url.go` — builds the redirect/callback URL from site config.
|
||||
- `register.go` — provider registration glue; tests in `register_test.go`.
|
||||
|
|
@ -51,6 +52,12 @@
|
|||
- Audit every provider/redirect/token error with sanitized messages; avoid logging secrets.
|
||||
- Prefer explicit scopes from configuration; defaults request only the minimal set.
|
||||
|
||||
#### RP-Initiated Logout
|
||||
|
||||
- The provider's `end_session_endpoint` is captured automatically from discovery by the wrapped `zitadel/oidc` RP and read via `GetEndSessionEndpoint()`; no separate config is needed.
|
||||
- `(*Client).EndSessionURL` constructs the redirect URL for the browser (it does not call the endpoint server-side, unlike `rp.EndSession`, because only the browser carries the provider's SSO cookie). It returns an empty string when the provider advertises no end-session endpoint, so callers fall back to a local-only logout.
|
||||
- The behavior is gated by `PHOTOPRISM_OIDC_LOGOUT` (default off) and enforced at sign-out in `internal/api/session_delete.go`.
|
||||
|
||||
### Security Group Extension for Entra ID
|
||||
|
||||
The following features are supported by the current implementation:
|
||||
|
|
|
|||
47
internal/auth/oidc/logout.go
Normal file
47
internal/auth/oidc/logout.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// EndSessionURL builds an RP-initiated logout URL for redirecting the browser to the
|
||||
// provider's end_session_endpoint, so the provider can end its own SSO session. It returns
|
||||
// an empty string (and no error) when the provider advertises no end_session_endpoint, so
|
||||
// callers can fall back to a local-only logout.
|
||||
//
|
||||
// The returned URL must be followed by a top-level browser navigation, not a server-side
|
||||
// request: only the browser carries the provider's SSO cookie that needs clearing. This is
|
||||
// why we build the URL here instead of using rp.EndSession, which performs the request
|
||||
// server-side.
|
||||
func (c *Client) EndSessionURL(idToken, postLogoutRedirectURI, state string) (string, error) {
|
||||
endpoint := c.GetEndSessionEndpoint()
|
||||
|
||||
if endpoint == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(endpoint)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("id_token_hint", idToken)
|
||||
|
||||
if clientID := c.OAuthConfig().ClientID; clientID != "" {
|
||||
q.Set("client_id", clientID)
|
||||
}
|
||||
|
||||
if postLogoutRedirectURI != "" {
|
||||
q.Set("post_logout_redirect_uri", postLogoutRedirectURI)
|
||||
}
|
||||
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
74
internal/auth/oidc/logout_test.go
Normal file
74
internal/auth/oidc/logout_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// fakeRelyingParty implements only the rp.RelyingParty methods EndSessionURL reads; any
|
||||
// other call panics on the nil embedded interface, which keeps the fake honest.
|
||||
type fakeRelyingParty struct {
|
||||
rp.RelyingParty
|
||||
endSession string
|
||||
clientID string
|
||||
}
|
||||
|
||||
func (f fakeRelyingParty) GetEndSessionEndpoint() string { return f.endSession }
|
||||
func (f fakeRelyingParty) OAuthConfig() *oauth2.Config { return &oauth2.Config{ClientID: f.clientID} }
|
||||
|
||||
func TestClient_EndSessionURL(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
c := &Client{RelyingParty: fakeRelyingParty{
|
||||
endSession: "https://provider.example/logout",
|
||||
clientID: "client-abc",
|
||||
}}
|
||||
|
||||
result, err := c.EndSessionURL("id-token-123", "https://app.localssl.dev/library/login", "state-xyz")
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := url.Parse(result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://provider.example/logout", u.Scheme+"://"+u.Host+u.Path)
|
||||
|
||||
q := u.Query()
|
||||
assert.Equal(t, "id-token-123", q.Get("id_token_hint"))
|
||||
assert.Equal(t, "client-abc", q.Get("client_id"))
|
||||
assert.Equal(t, "https://app.localssl.dev/library/login", q.Get("post_logout_redirect_uri"))
|
||||
assert.Equal(t, "state-xyz", q.Get("state"))
|
||||
})
|
||||
t.Run("NoEndSessionEndpoint", func(t *testing.T) {
|
||||
c := &Client{RelyingParty: fakeRelyingParty{endSession: "", clientID: "client-abc"}}
|
||||
|
||||
result, err := c.EndSessionURL("id-token-123", "https://app.localssl.dev/library/login", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", result)
|
||||
})
|
||||
t.Run("PreservesExistingQuery", func(t *testing.T) {
|
||||
c := &Client{RelyingParty: fakeRelyingParty{
|
||||
endSession: "https://provider.example/logout?realm=master",
|
||||
clientID: "client-abc",
|
||||
}}
|
||||
|
||||
result, err := c.EndSessionURL("id-token-123", "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := url.Parse(result)
|
||||
require.NoError(t, err)
|
||||
q := u.Query()
|
||||
assert.Equal(t, "master", q.Get("realm"))
|
||||
assert.Equal(t, "id-token-123", q.Get("id_token_hint"))
|
||||
assert.Equal(t, "", q.Get("post_logout_redirect_uri"))
|
||||
assert.Equal(t, "", q.Get("state"))
|
||||
})
|
||||
t.Run("InvalidEndpoint", func(t *testing.T) {
|
||||
c := &Client{RelyingParty: fakeRelyingParty{endSession: "://not a url", clientID: "client-abc"}}
|
||||
|
||||
_, err := c.EndSessionURL("id-token-123", "", "")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
|
@ -18,6 +18,9 @@ func ClientConfig(c *config.Config, t config.ClientType) config.Values {
|
|||
"icon": c.OIDCIcon(),
|
||||
"register": c.OIDCRegister(),
|
||||
"redirect": c.OIDCRedirect(),
|
||||
// logout lets the UI know sign-out will redirect to the provider's end-session
|
||||
// endpoint; the backend still drives the actual redirect URL.
|
||||
"logout": c.OIDCLogout(),
|
||||
"loginUri": c.OIDCLoginUri(),
|
||||
// cluster lets the frontend route a cluster-OIDC sign-out to the Portal login.
|
||||
"cluster": c.ClusterOIDC(),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ func TestClientConfig(t *testing.T) {
|
|||
assert.IsType(t, config.Values{}, result)
|
||||
assert.Equal(t, c.ClusterOIDC(), result["cluster"])
|
||||
assert.Equal(t, false, result["cluster"])
|
||||
assert.Equal(t, c.OIDCLogout(), result["logout"])
|
||||
assert.Equal(t, false, result["logout"])
|
||||
})
|
||||
t.Run("ClusterOIDC", func(t *testing.T) {
|
||||
c := config.NewConfig(config.CliTestContext())
|
||||
|
|
|
|||
|
|
@ -167,6 +167,12 @@ func (c *Config) OIDCRegister() bool {
|
|||
return c.options.OIDCRegister
|
||||
}
|
||||
|
||||
// OIDCLogout checks if signing out should also end the provider session via OpenID
|
||||
// Connect RP-initiated logout (redirect to the discovered end_session_endpoint).
|
||||
func (c *Config) OIDCLogout() bool {
|
||||
return c.options.OIDCLogout
|
||||
}
|
||||
|
||||
// OIDCUsername returns the preferred username claim for new users signing up via OIDC.
|
||||
func (c *Config) OIDCUsername() string {
|
||||
switch c.options.OIDCUsername {
|
||||
|
|
@ -291,6 +297,7 @@ func (c *Config) OIDCReport() (rows [][]string, cols []string) {
|
|||
{"oidc-icon", c.OIDCIcon()},
|
||||
{"oidc-redirect", fmt.Sprintf("%t", c.OIDCRedirect())},
|
||||
{"oidc-register", fmt.Sprintf("%t", c.OIDCRegister())},
|
||||
{"oidc-logout", fmt.Sprintf("%t", c.OIDCLogout())},
|
||||
{"oidc-username", c.OIDCUsername()},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -263,6 +263,16 @@ func TestConfig_OIDCRegister(t *testing.T) {
|
|||
assert.False(t, c.OIDCRegister())
|
||||
}
|
||||
|
||||
func TestConfig_OIDCLogout(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
assert.False(t, c.OIDCLogout())
|
||||
|
||||
c.options.OIDCLogout = true
|
||||
|
||||
assert.True(t, c.OIDCLogout())
|
||||
}
|
||||
|
||||
func TestConfig_OIDCRole(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,11 @@ var Flags = CliFlags{
|
|||
Usage: "allows new users to create an account when they sign in with OpenID Connect",
|
||||
EnvVars: EnvVars("OIDC_REGISTER"),
|
||||
}}, {
|
||||
Flag: &cli.BoolFlag{
|
||||
Name: "oidc-logout",
|
||||
Usage: "ends the provider session on sign-out via OpenID Connect RP-initiated logout",
|
||||
EnvVars: EnvVars("OIDC_LOGOUT"),
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "oidc-username",
|
||||
Usage: "preferred username `CLAIM` for new OpenID Connect users (preferred_username, name, nickname, email)",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type Options struct {
|
|||
OIDCIcon string `yaml:"OIDCIcon" json:"OIDCIcon" flag:"oidc-icon"`
|
||||
OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"`
|
||||
OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"`
|
||||
OIDCLogout bool `yaml:"OIDCLogout" json:"OIDCLogout" flag:"oidc-logout"`
|
||||
OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"`
|
||||
OIDCGroupClaim string `yaml:"OIDCGroupClaim" json:"-" flag:"oidc-group-claim" tags:"portal,pro"`
|
||||
OIDCGroup []string `yaml:"OIDCGroup" json:"-" flag:"oidc-group" tags:"portal,pro"`
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
|||
api.OAuthUserinfo(APIv1)
|
||||
api.OAuthToken(APIv1)
|
||||
api.OAuthRevoke(APIv1)
|
||||
api.OAuthLogout(APIv1)
|
||||
|
||||
// OIDC Client Endpoints.
|
||||
api.OIDCLogin(APIv1)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ func NewPortalOpenIDConfiguration(conf *config.Config) *OpenIDConfiguration {
|
|||
AuthorizationEndpoint: issuer + config.ApiUri + "/oauth/authorize",
|
||||
TokenEndpoint: issuer + config.ApiUri + "/oauth/token",
|
||||
UserinfoEndpoint: issuer + config.ApiUri + "/oauth/userinfo",
|
||||
EndSessionEndpoint: issuer + config.ApiUri + "/oauth/logout",
|
||||
JwksUri: issuer + "/.well-known/jwks.json",
|
||||
ResponseTypesSupported: PortalOIDCResponseTypes,
|
||||
GrantTypesSupported: PortalOIDCGrantTypes,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ func TestPortalOpenIDConfiguration(t *testing.T) {
|
|||
assert.Equal(t, "http://localhost:2342/api/v1/oauth/authorize", result.AuthorizationEndpoint)
|
||||
assert.Equal(t, "http://localhost:2342/api/v1/oauth/token", result.TokenEndpoint)
|
||||
assert.Equal(t, "http://localhost:2342/api/v1/oauth/userinfo", result.UserinfoEndpoint)
|
||||
assert.Equal(t, "http://localhost:2342/api/v1/oauth/logout", result.EndSessionEndpoint)
|
||||
assert.Equal(t, "http://localhost:2342/.well-known/jwks.json", result.JwksUri)
|
||||
})
|
||||
t.Run("Capabilities", func(t *testing.T) {
|
||||
|
|
@ -46,13 +47,14 @@ func TestPortalOpenIDConfiguration(t *testing.T) {
|
|||
c.Options().SiteUrl = "http://foo:2342/foo/"
|
||||
result := NewPortalOpenIDConfiguration(c)
|
||||
|
||||
for _, u := range []string{result.Issuer, result.AuthorizationEndpoint, result.TokenEndpoint, result.UserinfoEndpoint, result.JwksUri} {
|
||||
for _, u := range []string{result.Issuer, result.AuthorizationEndpoint, result.TokenEndpoint, result.UserinfoEndpoint, result.EndSessionEndpoint, result.JwksUri} {
|
||||
assert.Equal(t, 1, strings.Count(u, "/foo/"), "base path must appear exactly once in %s", u)
|
||||
assert.NotContains(t, u, "/foo/foo/", "base path must not be doubled in %s", u)
|
||||
}
|
||||
assert.Equal(t, "http://foo:2342/foo/api/v1/oauth/authorize", result.AuthorizationEndpoint)
|
||||
assert.Equal(t, "http://foo:2342/foo/api/v1/oauth/token", result.TokenEndpoint)
|
||||
assert.Equal(t, "http://foo:2342/foo/api/v1/oauth/userinfo", result.UserinfoEndpoint)
|
||||
assert.Equal(t, "http://foo:2342/foo/api/v1/oauth/logout", result.EndSessionEndpoint)
|
||||
assert.Equal(t, "http://foo:2342/foo/.well-known/jwks.json", result.JwksUri)
|
||||
})
|
||||
t.Run("IssuerWithTrailingSlashIsNormalized", func(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue