key-cape/src/internal/server/oidc/account.go
tegwick 13afaa916d
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m20s
Authentication acceptance / provider-contract (push) Successful in 14s
Build and Publish Container Image / build-and-push (push) Successful in 44s
Style the NetKingdom sign-out confirmation like the account site.
Assistant: grok
Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f
2026-09-27 00:51:14 +02:00

116 lines
5.1 KiB
Go

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"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign out of NetKingdom · NetKingdom Identity</title><style>
:root{--ink:#17201c;--paper:#f5f1e8;--accent:#195b47;--line:#c8c1b3}
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:18px/1.55 system-ui,sans-serif}
header,main{max-width:68rem;margin:auto;padding:1.25rem}header{border-bottom:1px solid var(--line)}
h1{font:clamp(2.2rem,7vw,5.5rem)/.98 Georgia,serif;max-width:13ch}a{color:var(--accent)}
form{display:grid;gap:.8rem;max-width:42rem}
input,button{font:inherit;padding:.65rem}button{background:var(--accent);color:white;border:0;border-radius:.3rem;cursor:pointer}
a:focus-visible,button:focus-visible{outline:3px solid #e59f24;outline-offset:3px}
@media(max-width:640px){body{font-size:16px}}
</style></head><body><header><strong>NetKingdom Identity</strong></header><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></body></html>`))
func (h *AccountLogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
// Keep the same-origin POST Origin; no-referrer makes Chromium send Origin: null.
w.Header().Set("Referrer-Policy", "same-origin")
// 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'; style-src 'unsafe-inline'; form-action 'self' "+destinations+"; frame-ancestors 'none'; base-uri 'none'")
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)
}
}