2026-09-12 10:34:41 +02:00
|
|
|
package oidc
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"crypto/subtle"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"html/template"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (h *AuthorizeHandler) authenticationFailure(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
h.browserFailure(w, r, http.StatusUnauthorized, "authentication failed")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthorizeHandler) browserFailure(w http.ResponseWriter, r *http.Request, status int, message string) {
|
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
|
|
|
if h.AccountPortalURL != "" {
|
|
|
|
|
// Never carry upstream code, state, error details or an unverified identity.
|
|
|
|
|
http.Redirect(w, r, strings.TrimRight(h.AccountPortalURL, "/")+"/access-recovery", http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
http.Error(w, message, status)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AccountLogoutHandler confirms browser-wide sign-out without accepting a return URL
|
|
|
|
|
// from the browser. Authelia owns the upstream cookie and destroys it on its origin.
|
|
|
|
|
type AccountLogoutHandler struct {
|
|
|
|
|
PortalURL string
|
|
|
|
|
UpstreamLogoutURL string
|
|
|
|
|
Issuer string
|
|
|
|
|
Logins *LoginSessionStore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const logoutCSRF = "__Host-keycape-logout"
|
|
|
|
|
|
|
|
|
|
var accountLogoutPage = template.Must(template.New("logout").Parse(`<!doctype html>
|
|
|
|
|
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
|
|
|
<title>Sign out of NetKingdom</title><main><h1>Sign out of NetKingdom?</h1>
|
|
|
|
|
<p>This ends your shared sign-in session in this browser so you can use another account.
|
|
|
|
|
Applications that already have their own sessions may remain signed in.</p>
|
|
|
|
|
<form method="post" action="/account/logout"><input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
|
|
|
<button type="submit">Sign out of NetKingdom</button></form>
|
|
|
|
|
<p><a href="{{.Portal}}">Back to my account</a></p></main></html>`))
|
|
|
|
|
|
|
|
|
|
func (h *AccountLogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
2026-09-12 10:43:32 +02:00
|
|
|
// Keep the same-origin POST Origin; no-referrer makes Chromium send Origin: null.
|
|
|
|
|
w.Header().Set("Referrer-Policy", "same-origin")
|
2026-09-12 10:47:33 +02:00
|
|
|
// Chromium applies form-action to the POST redirect chain as well.
|
|
|
|
|
upstream, _ := url.Parse(h.UpstreamLogoutURL)
|
|
|
|
|
portal, _ := url.Parse(h.PortalURL)
|
|
|
|
|
destinations := upstream.Scheme + "://" + upstream.Host + " " + portal.Scheme + "://" + portal.Host
|
|
|
|
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; form-action 'self' "+destinations+"; frame-ancestors 'none'; base-uri 'none'")
|
2026-09-12 10:34:41 +02:00
|
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
|
switch r.Method {
|
|
|
|
|
case http.MethodGet:
|
|
|
|
|
var nonce [32]byte
|
|
|
|
|
if _, err := rand.Read(nonce[:]); err != nil {
|
|
|
|
|
http.Error(w, "sign-out unavailable", 503)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
csrf := base64.RawURLEncoding.EncodeToString(nonce[:])
|
|
|
|
|
http.SetCookie(w, &http.Cookie{Name: logoutCSRF, Value: csrf, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 600})
|
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
_ = accountLogoutPage.Execute(w, struct{ CSRF, Portal string }{csrf, h.PortalURL})
|
|
|
|
|
case http.MethodPost:
|
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
http.Error(w, "invalid form", 400)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cookie, err := r.Cookie(logoutCSRF)
|
|
|
|
|
csrf := r.PostForm.Get("csrf")
|
|
|
|
|
origin, parseErr := url.Parse(h.Issuer)
|
|
|
|
|
expected := ""
|
|
|
|
|
if parseErr == nil {
|
|
|
|
|
expected = origin.Scheme + "://" + origin.Host
|
|
|
|
|
}
|
|
|
|
|
if err != nil || len(csrf) != 43 || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(csrf)) != 1 || r.Header.Get("Origin") != expected {
|
|
|
|
|
http.Error(w, "sign-out confirmation expired; reload this page", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if session := h.Logins.fromRequest(r); session != nil {
|
|
|
|
|
h.Logins.Delete(session.ID)
|
|
|
|
|
}
|
|
|
|
|
clearLoginCookie(w, issuerIsHTTPS(h.Issuer))
|
|
|
|
|
http.SetCookie(w, &http.Cookie{Name: logoutCSRF, Value: "", Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
|
|
|
|
target, err := url.Parse(h.UpstreamLogoutURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "sign-out unavailable", 503)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
q := target.Query()
|
|
|
|
|
q.Set("rd", strings.TrimRight(h.PortalURL, "/")+"/logged-out")
|
|
|
|
|
target.RawQuery = q.Encode()
|
|
|
|
|
http.Redirect(w, r, target.String(), http.StatusSeeOther)
|
|
|
|
|
default:
|
|
|
|
|
w.Header().Set("Allow", "GET, POST")
|
|
|
|
|
http.Error(w, "method not allowed", 405)
|
|
|
|
|
}
|
|
|
|
|
}
|