photoprism/internal/api/oidc_session_cookie.go

259 lines
8.5 KiB
Go

package api
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
)
// OIDCSessionCookie is the name of the narrowly-scoped cookie that lets the
// Portal OIDC OP authenticate a browser on a top-level navigation to
// /api/v1/oauth/authorize, which carries no Authorization or X-Auth-Token header.
const OIDCSessionCookie = "oidc_session"
// OIDCSessionCookieTTL bounds how long the OP session-signal cookie stays valid.
// It is short because the cookie only needs to bridge the brief window between a
// header-less top-level navigation to /api/v1/oauth/authorize and the SPA
// re-presenting its session; it is refreshed on every authenticated session GET.
const OIDCSessionCookieTTL = 10 * time.Minute
const (
oidcSessionKeyFile = "oidc_session.key"
oidcSessionKeyLen = 32
)
var (
oidcSessionKeyOnce sync.Once
oidcSessionKey []byte
)
// OIDCSessionCookiePath returns the URL path the OP session cookie is scoped to,
// derived from where the OAuth endpoints are mounted (the APIv1 base path plus
// "/oauth"). Scoping to the OAuth subtree keeps the cookie off the general API
// surface, so it adds no CSRF vector to state-changing endpoints, while still
// covering the /oauth/authorize endpoint a top-level browser navigation hits.
func OIDCSessionCookiePath(conf *config.Config) string {
if conf == nil {
return config.ApiUri + "/oauth"
}
return conf.BaseUri(config.ApiUri) + "/oauth"
}
// OIDCSessionCookieClearPath returns the path at which the OP session cookie is
// cleared on logout, or "" when this node has no OP cookie to clear. On the Portal
// it is the local OP path; on an instance whose OIDC OP is the Portal it is derived
// from the issuer (the cookie lives at the Portal's shared-domain path, not the
// instance's /i/<tenant> base), so a cluster-wide Sign-Out works from any node.
func OIDCSessionCookieClearPath(conf *config.Config) string {
if conf == nil {
return ""
}
if conf.Portal() {
return OIDCSessionCookiePath(conf)
}
if conf.OIDCIssuerOnSiteDomain() {
return strings.TrimRight(conf.OIDCUri().Path, "/") + config.ApiUri + "/oauth"
}
return ""
}
// oidcSessionSignalKey loads (or generates and persists) the HMAC key used to
// sign the OP session-signal cookie, kept under the Portal config keys
// directory. It falls back to a process-local random key when persistence is
// unavailable so sign and verify still agree within a single process.
func oidcSessionSignalKey() []byte {
oidcSessionKeyOnce.Do(func() {
if key := loadOrCreateOIDCSessionKey(); len(key) >= oidcSessionKeyLen {
oidcSessionKey = key
return
}
key := make([]byte, oidcSessionKeyLen)
_, _ = rand.Read(key)
oidcSessionKey = key
log.Warnf("oidc: using a process-local session-signal key because the persistent key could not be loaded or stored; OP session cookies will not verify across restarts or replicas")
})
return oidcSessionKey
}
// loadOrCreateOIDCSessionKey reads the persisted HMAC key, creating it on first
// use. Returns nil when no config or writable key path is available.
func loadOrCreateOIDCSessionKey() []byte {
conf := get.Config()
if conf == nil {
return nil
}
dir := filepath.Join(conf.PortalConfigPath(), "keys")
path := filepath.Join(dir, oidcSessionKeyFile)
if b, err := os.ReadFile(path); err == nil && len(b) >= oidcSessionKeyLen {
return b
}
if err := fs.MkdirAll(dir); err != nil {
return nil
}
key := make([]byte, oidcSessionKeyLen)
if _, err := rand.Read(key); err != nil {
return nil
}
if err := os.WriteFile(path, key, fs.ModeSecretFile); err != nil {
return nil
}
return key
}
// signOIDCSession returns a URL-safe, HMAC-signed value binding sessionID to
// exp. The payload is authenticated but not secret: it carries the session id
// (a hash of the bearer token, not the token itself), so the value is opaque to
// the browser and cannot be replayed against any other endpoint.
func signOIDCSession(sessionID string, exp time.Time) string {
payload := sessionID + "|" + strconv.FormatInt(exp.Unix(), 10)
mac := hmac.New(sha256.New, oidcSessionSignalKey())
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// parseOIDCSession verifies a signed value and returns the embedded session id
// when the signature is valid and the value has not expired. The HMAC check is
// constant-time, so a forged or tampered value cannot be distinguished by
// response timing.
func parseOIDCSession(value string) (sessionID string, ok bool) {
encPayload, encSig, found := strings.Cut(value, ".")
if !found {
return "", false
}
payload, err := base64.RawURLEncoding.DecodeString(encPayload)
if err != nil {
return "", false
}
sig, err := base64.RawURLEncoding.DecodeString(encSig)
if err != nil {
return "", false
}
mac := hmac.New(sha256.New, oidcSessionSignalKey())
mac.Write(payload)
if !hmac.Equal(sig, mac.Sum(nil)) {
return "", false
}
id, expStr, found := strings.Cut(string(payload), "|")
if !found {
return "", false
}
expUnix, err := strconv.ParseInt(expStr, 10, 64)
if err != nil || time.Now().After(time.Unix(expUnix, 0)) {
return "", false
}
if !rnd.IsSessionID(id) {
return "", false
}
return id, true
}
// SetOIDCSessionCookie writes the OP session-signal cookie for sess: a
// short-lived, HMAC-signed reference to the session id, NOT the bearer token.
// The OIDC OP /api/v1/oauth/authorize endpoint reads it to resume an
// authenticated browser on a top-level navigation that carries no Authorization
// header. Because the value is the session id (a hash of the token) rather than
// the token, a leaked cookie cannot authenticate any other API endpoint, and
// the HMAC signature plus short TTL prevent forging or extending it.
func SetOIDCSessionCookie(c *gin.Context, sess *entity.Session, cookiePath string, secure bool) {
// Only user sessions are eligible: the OP authorize endpoint resumes a user,
// and the reader rejects user-less (client/service) sessions via NoUser(), so
// setting the cookie for them would only emit a signal that can never resolve.
if c == nil || sess == nil || !rnd.IsSessionID(sess.ID) || sess.NoUser() {
return
}
if cookiePath == "" {
cookiePath = config.ApiUri + "/oauth"
}
value := signOIDCSession(sess.ID, time.Now().Add(OIDCSessionCookieTTL))
http.SetCookie(c.Writer, &http.Cookie{
Name: OIDCSessionCookie,
Value: value,
Path: cookiePath,
MaxAge: int(OIDCSessionCookieTTL.Seconds()),
Secure: secure,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// ClearOIDCSessionCookie removes the OP session cookie, e.g. on logout. The
// cookiePath must match the value used by SetOIDCSessionCookie so the browser
// overwrites the same cookie.
func ClearOIDCSessionCookie(c *gin.Context, cookiePath string, secure bool) {
if c == nil {
return
}
if cookiePath == "" {
cookiePath = config.ApiUri + "/oauth"
}
http.SetCookie(c.Writer, &http.Cookie{
Name: OIDCSessionCookie,
Value: "",
Path: cookiePath,
MaxAge: -1,
Secure: secure,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// OIDCSessionCookieSession resolves the Portal session referenced by the OP
// session-signal cookie, or nil when the cookie is absent, invalid, expired, or
// no longer maps to an active user session. A present-but-unresolvable cookie is
// cleared so the browser stops sending a stale signal. It is the only reader of
// the cookie and must be used solely by the OIDC OP authorize handler as a
// fallback when no Authorization/X-Auth-Token header is present.
func OIDCSessionCookieSession(c *gin.Context) *entity.Session {
if c == nil {
return nil
}
raw, err := c.Cookie(OIDCSessionCookie)
if err != nil || raw == "" {
return nil
}
conf := get.Config()
cookiePath := config.ApiUri + "/oauth"
secure := false
if conf != nil {
cookiePath = OIDCSessionCookiePath(conf)
secure = conf.SiteHttps()
}
sessionID, ok := parseOIDCSession(raw)
if !ok {
ClearOIDCSessionCookie(c, cookiePath, secure)
return nil
}
sess, err := entity.FindSession(sessionID)
if err != nil || sess == nil || sess.Invalid() || sess.NoUser() {
ClearOIDCSessionCookie(c, cookiePath, secure)
return nil
}
return sess
}