Add central login recovery and confirmed shared sign-out
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 44s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 44s
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
89694ad6df
commit
074c2ce498
9 changed files with 334 additions and 49 deletions
17
src/internal/config/account_test.go
Normal file
17
src/internal/config/account_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package config
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
func TestAccountRecoveryDestinationsAreOwnerConfiguredHTTPS(t *testing.T) {
|
||||
for _, raw := range []string{"http://users.example", "https://user:password@users.example", "https://users.example/?next=evil", "https://users.example/#fragment", "//users.example"} {
|
||||
cfg:=&Config{AccountPortalURL:raw,BrowserLogoutURL:"https://auth.example/logout"}
|
||||
found:=false
|
||||
for _, err:=range ValidateConfig(cfg) {if strings.HasPrefix(err,"accountPortalURL:") {found=true}}
|
||||
if !found {t.Errorf("accepted unsafe account URL %q",raw)}
|
||||
}
|
||||
cfg:=&Config{AccountPortalURL:"https://users.example"}
|
||||
found:=false
|
||||
for _, err:=range ValidateConfig(cfg) {if strings.Contains(err,"configured together") {found=true}}
|
||||
if !found {t.Fatal("accepted incomplete logout route")}
|
||||
}
|
||||
|
|
@ -18,16 +18,18 @@ import (
|
|||
|
||||
// Config is the top-level server configuration.
|
||||
type Config struct {
|
||||
Issuer string `yaml:"issuer"`
|
||||
Port int `yaml:"port"`
|
||||
TokenLifetime string `yaml:"tokenLifetime"`
|
||||
PrivateKeyPEM string `yaml:"privateKeyPem"`
|
||||
LLDAP lldap.Config `yaml:"lldap"`
|
||||
Authelia authelia.Config `yaml:"authelia"`
|
||||
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
|
||||
Clients []ClientConfig `yaml:"clients"`
|
||||
Environment string `yaml:"environment"`
|
||||
TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"`
|
||||
AccountPortalURL string `yaml:"accountPortalURL,omitempty"`
|
||||
BrowserLogoutURL string `yaml:"browserLogoutURL,omitempty"`
|
||||
Issuer string `yaml:"issuer"`
|
||||
Port int `yaml:"port"`
|
||||
TokenLifetime string `yaml:"tokenLifetime"`
|
||||
PrivateKeyPEM string `yaml:"privateKeyPem"`
|
||||
LLDAP lldap.Config `yaml:"lldap"`
|
||||
Authelia authelia.Config `yaml:"authelia"`
|
||||
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
|
||||
Clients []ClientConfig `yaml:"clients"`
|
||||
Environment string `yaml:"environment"`
|
||||
TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"`
|
||||
}
|
||||
|
||||
// TenantEngineConfig configures the optional tenant_roles cache claim.
|
||||
|
|
@ -85,6 +87,12 @@ func Load(path string) (*Config, error) {
|
|||
return nil, fmt.Errorf("config: parse %q: %w", path, err)
|
||||
}
|
||||
|
||||
if value := os.Getenv("KEYCAPE_ACCOUNT_PORTAL_URL"); value != "" {
|
||||
cfg.AccountPortalURL = value
|
||||
}
|
||||
if value := os.Getenv("KEYCAPE_BROWSER_LOGOUT_URL"); value != "" {
|
||||
cfg.BrowserLogoutURL = value
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,18 @@ import (
|
|||
// Called at startup — the server must exit 1 if any errors are returned.
|
||||
func ValidateConfig(cfg *Config) []string {
|
||||
var errs []string
|
||||
for name, raw := range map[string]string{"accountPortalURL": cfg.AccountPortalURL, "browserLogoutURL": cfg.BrowserLogoutURL} {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
errs = append(errs, name+": must be an HTTPS URL without credentials, query or fragment")
|
||||
}
|
||||
}
|
||||
if (cfg.AccountPortalURL == "") != (cfg.BrowserLogoutURL == "") {
|
||||
errs = append(errs, "accountPortalURL and browserLogoutURL must be configured together")
|
||||
}
|
||||
|
||||
// Issuer must be a valid URL with an http(s) scheme.
|
||||
if cfg.Issuer == "" {
|
||||
|
|
|
|||
99
src/internal/server/oidc/account.go
Normal file
99
src/internal/server/oidc/account.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
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")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; form-action 'self'; 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)
|
||||
}
|
||||
}
|
||||
73
src/internal/server/oidc/account_test.go
Normal file
73
src/internal/server/oidc/account_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"keycape/internal/domain"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecoveryStripsCredentials(t *testing.T) {
|
||||
h := &AuthorizeHandler{AccountPortalURL: "https://users.example"}
|
||||
w := httptest.NewRecorder()
|
||||
h.authenticationFailure(w, httptest.NewRequest("GET", "https://kc.example/authorize/callback?code=secret&state=secret&redirect=https://evil.example", nil))
|
||||
if w.Code != 303 || w.Header().Get("Location") != "https://users.example/access-recovery" {
|
||||
t.Fatal(w.Code, w.Header())
|
||||
}
|
||||
if w.Header().Get("Referrer-Policy") != "no-referrer" || strings.Contains(w.Body.String(), "secret") {
|
||||
t.Fatal("callback leaked")
|
||||
}
|
||||
}
|
||||
func TestSharedLogoutRequiresConfirmationAndFixedReturn(t *testing.T) {
|
||||
h := &AccountLogoutHandler{PortalURL: "https://users.example", UpstreamLogoutURL: "https://auth.example/logout", Issuer: "https://kc.example", Logins: NewLoginSessionStore()}
|
||||
session := h.Logins.Create("synthetic", domain.AssuranceAAL1)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest("GET", "https://kc.example/account/logout", nil))
|
||||
if _, ok := h.Logins.Get(session.ID); !ok {
|
||||
t.Fatal("GET logged out")
|
||||
}
|
||||
if w.Code != 200 || w.Header().Get("Location") != "" {
|
||||
t.Fatal("GET must only confirm")
|
||||
}
|
||||
c := w.Result().Cookies()[0]
|
||||
if !c.Secure || !c.HttpOnly || c.Domain != "" {
|
||||
t.Fatal("unsafe CSRF cookie")
|
||||
}
|
||||
for _, origin := range []string{"", "https://evil.example", "https://kc.example"} {
|
||||
form := url.Values{"csrf": {c.Value}, "rd": {"https://evil.example"}}
|
||||
r := httptest.NewRequest("POST", "https://kc.example/account/logout", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.Header.Set("Origin", origin)
|
||||
r.AddCookie(c)
|
||||
r.AddCookie(&http.Cookie{Name: loginCookieName, Value: session.ID})
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if origin != "https://kc.example" {
|
||||
if w.Code != 403 {
|
||||
t.Fatal("bad origin accepted")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, ok := h.Logins.Get(session.ID); ok {
|
||||
t.Fatal("local session survived")
|
||||
}
|
||||
if w.Code != 303 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
dest, _ := url.Parse(w.Header().Get("Location"))
|
||||
if dest.Host != "auth.example" || dest.Query().Get("rd") != "https://users.example/logged-out" {
|
||||
t.Fatal("untrusted return")
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest("POST", "https://kc.example/account/logout", strings.NewReader("csrf=wrong"))
|
||||
r.Header.Set("Origin", "https://kc.example")
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(c)
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatal("bad csrf accepted")
|
||||
}
|
||||
}
|
||||
|
|
@ -66,14 +66,15 @@ func (p *pendingStateStore) Delete(state string) {
|
|||
|
||||
// AuthorizeHandler implements GET /authorize and GET /authorize/callback.
|
||||
type AuthorizeHandler struct {
|
||||
ClientConfig map[string]*domain.Client
|
||||
Auth domain.AuthProvider
|
||||
MFA domain.MFAProvider
|
||||
Sessions *SessionStore
|
||||
Logins *LoginSessionStore
|
||||
Handoffs *HandoffStore
|
||||
Issuer string
|
||||
Emitter telemetry.Emitter
|
||||
AccountPortalURL string
|
||||
ClientConfig map[string]*domain.Client
|
||||
Auth domain.AuthProvider
|
||||
MFA domain.MFAProvider
|
||||
Sessions *SessionStore
|
||||
Logins *LoginSessionStore
|
||||
Handoffs *HandoffStore
|
||||
Issuer string
|
||||
Emitter telemetry.Emitter
|
||||
|
||||
pending *pendingStateStore
|
||||
once sync.Once
|
||||
|
|
@ -256,7 +257,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||
PKCEChallengeMethod: codeChallengeMethod,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "upstream auth provider error", http.StatusBadGateway)
|
||||
h.browserFailure(w, r, http.StatusBadGateway, "upstream auth provider error")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -274,7 +275,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
h.browserFailure(w, r, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -287,12 +288,12 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
// Recover pending state keyed by state param.
|
||||
ps, ok := h.pending.Load(state)
|
||||
if !ok {
|
||||
http.Error(w, "unknown or expired state", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "unknown or expired state")
|
||||
return
|
||||
}
|
||||
if time.Now().After(ps.ExpiresAt) {
|
||||
h.pending.Delete(state)
|
||||
http.Error(w, "authorization request expired", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "authorization request expired")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -315,7 +316,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
return
|
||||
}
|
||||
h.pending.Delete(state)
|
||||
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
||||
h.authenticationFailure(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -330,12 +331,12 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
Result: "failure",
|
||||
ErrorType: "mfa_check_error",
|
||||
})
|
||||
http.Error(w, "mfa check error", http.StatusInternalServerError)
|
||||
h.browserFailure(w, r, http.StatusInternalServerError, "mfa check error")
|
||||
return
|
||||
}
|
||||
if decision.RequireMFA {
|
||||
if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil {
|
||||
http.Error(w, "enrollment check error", http.StatusInternalServerError)
|
||||
h.browserFailure(w, r, http.StatusInternalServerError, "enrollment check error")
|
||||
return
|
||||
} else if handed {
|
||||
return
|
||||
|
|
@ -349,7 +350,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
|
||||
if errors.Is(err, domain.ErrMFANotEnrolled) {
|
||||
if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil {
|
||||
http.Error(w, "enrollment check error", http.StatusInternalServerError)
|
||||
h.browserFailure(w, r, http.StatusInternalServerError, "enrollment check error")
|
||||
return
|
||||
} else if handed {
|
||||
return
|
||||
|
|
@ -357,7 +358,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
h.pending.Delete(state)
|
||||
h.emitMFAFailure(ctx, ps.ClientID)
|
||||
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
||||
h.browserFailure(w, r, http.StatusUnauthorized, "MFA validation failed")
|
||||
return
|
||||
}
|
||||
h.pending.Delete(state)
|
||||
|
|
@ -398,7 +399,7 @@ func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState
|
|||
func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -407,17 +408,17 @@ func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Req
|
|||
|
||||
ps, ok := h.pending.Load(state)
|
||||
if !ok {
|
||||
http.Error(w, "unknown or expired state", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "unknown or expired state")
|
||||
return
|
||||
}
|
||||
if time.Now().After(ps.ExpiresAt) {
|
||||
h.pending.Delete(state)
|
||||
http.Error(w, "authorization request expired", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "authorization request expired")
|
||||
return
|
||||
}
|
||||
if ps.AuthenticatedUser == "" {
|
||||
h.pending.Delete(state)
|
||||
http.Error(w, "mfa challenge not active", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "mfa challenge not active")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(mfaToken) == "" {
|
||||
|
|
@ -428,7 +429,7 @@ func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Req
|
|||
if err := h.MFA.ValidateMFAToken(ctx, ps.AuthenticatedUser, mfaToken); err != nil {
|
||||
h.pending.Delete(state)
|
||||
h.emitMFAFailure(ctx, ps.ClientID)
|
||||
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
||||
h.browserFailure(w, r, http.StatusUnauthorized, "MFA validation failed")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -485,7 +486,7 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.
|
|||
// Redirect to client with code and state.
|
||||
redirectTo, err := url.Parse(ps.RedirectURI)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid redirect_uri", http.StatusInternalServerError)
|
||||
h.browserFailure(w, r, http.StatusInternalServerError, "invalid redirect_uri")
|
||||
return
|
||||
}
|
||||
q := redirectTo.Query()
|
||||
|
|
@ -515,7 +516,7 @@ func (h *AuthorizeHandler) startHandoff(w http.ResponseWriter, r *http.Request,
|
|||
}
|
||||
token, err := h.Handoffs.Issue(kind, ps)
|
||||
if err != nil {
|
||||
http.Error(w, "handoff error", http.StatusInternalServerError)
|
||||
h.browserFailure(w, r, http.StatusInternalServerError, "handoff error")
|
||||
return
|
||||
}
|
||||
loc, err := appendHandoff(dest, token)
|
||||
|
|
@ -530,18 +531,18 @@ func (h *AuthorizeHandler) startHandoff(w http.ResponseWriter, r *http.Request,
|
|||
func (h *AuthorizeHandler) serveRegisterFromPending(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
h.browserFailure(w, r, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
state := r.URL.Query().Get("state")
|
||||
ps, ok := h.pending.Load(state)
|
||||
if !ok {
|
||||
http.Error(w, "unknown or expired state", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "unknown or expired state")
|
||||
return
|
||||
}
|
||||
if time.Now().After(ps.ExpiresAt) {
|
||||
h.pending.Delete(state)
|
||||
http.Error(w, "authorization request expired", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "authorization request expired")
|
||||
return
|
||||
}
|
||||
h.startHandoff(w, r, ps, HandoffRegister)
|
||||
|
|
@ -550,20 +551,20 @@ func (h *AuthorizeHandler) serveRegisterFromPending(w http.ResponseWriter, r *ht
|
|||
func (h *AuthorizeHandler) serveHandoffReturn(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
h.browserFailure(w, r, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
token := r.URL.Query().Get("kc_handoff")
|
||||
env, err := h.Handoffs.Consume(token)
|
||||
switch {
|
||||
case errors.Is(err, errHandoffExpired):
|
||||
http.Error(w, "handoff expired", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "handoff expired")
|
||||
return
|
||||
case errors.Is(err, errHandoffReplay):
|
||||
http.Error(w, "handoff already used", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "handoff already used")
|
||||
return
|
||||
case err != nil:
|
||||
http.Error(w, "invalid handoff", http.StatusBadRequest)
|
||||
h.browserFailure(w, r, http.StatusBadRequest, "invalid handoff")
|
||||
return
|
||||
}
|
||||
client, ok := h.ClientConfig[env.ClientID]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue