Report private handoff failure stages separately from expired links
5 files changed,
+185
−12
internal/home/directory.go+1 −1internal/home/exchange.go+11 −11internal/home/handoff_failure.go+77 −0internal/home/handoff_failure_test.go+90 −0internal/home/templates/handoff_failed.html+6 −0
CI passed — run details
diff --git a/internal/home/directory.go b/internal/home/directory.go| index a84aeac..9ce7ec9 100644 |
| --- a/internal/home/directory.go |
| +++ b/internal/home/directory.go |
| @@ -48,7 +48,7 @@ func newDirectory(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) { |
| }, Complete: func(w http.ResponseWriter, r *http.Request, c assertion.Claims) error { |
| return a.sessions.SignIn(w, r, sessions.Session{Subject: c.Subject, Method: "assertion", AuthTime: time.Unix(c.AuthTime, 0)}) |
| }, Fail: func(w http.ResponseWriter, r *http.Request, status int) { |
| - a.render(w, status, "signin_failed.html", map[string]any{}) |
| + a.failDirectoryHandoff(w, r, status) |
| }}) |
| if err != nil { |
| return nil, err |
diff --git a/internal/home/exchange.go b/internal/home/exchange.go| index e139590..6b950e9 100644 |
| --- a/internal/home/exchange.go |
| +++ b/internal/home/exchange.go |
| @@ -28,7 +28,7 @@ func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) { |
| maxAge := 0 |
| if values, present := q["max_age"]; present { |
| if len(values) != 1 || values[0] != "300" { |
| - a.signinFailed(w) |
| + a.failAssertion(w, authorizeAge) |
| return |
| } |
| maxAge = 300 |
| @@ -38,7 +38,7 @@ func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) { |
| wantFields++ |
| } |
| if err != nil || len(q) != wantFields || len(q["consumer"]) != 1 || len(q["nonce"]) != 1 || !handoff.ValidNonce(q.Get("nonce")) || a.runtime.consumers[q.Get("consumer")] == "" { |
| - a.signinFailed(w) |
| + a.failAssertion(w, authorizeRequest) |
| return |
| } |
| intent := assertionIntent{Consumer: q.Get("consumer"), Nonce: q.Get("nonce"), Expires: time.Now().Unix() + 600, MaxAge: maxAge} |
| @@ -49,12 +49,12 @@ func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) { |
| } |
| b, err := json.Marshal(intent) |
| if err != nil { |
| - a.signinFailed(w) |
| + a.failAssertion(w, authorizeEncode) |
| return |
| } |
| sealed, err := a.secrets.Sealer.Seal("assertion.intent", b) |
| if err != nil { |
| - a.signinFailed(w) |
| + a.failAssertion(w, authorizeSeal) |
| return |
| } |
| http.SetCookie(w, &http.Cookie{Name: assertionIntentCookie, Value: base64.RawURLEncoding.EncodeToString(sealed), Path: "/", MaxAge: 600, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) |
| @@ -65,23 +65,23 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) { |
| // no-referrer would turn the cross-origin form POST's Origin into null. |
| w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") |
| if r.URL.RawQuery != "" { |
| - a.signinFailed(w) |
| + a.failAssertion(w, assertionQuery) |
| return |
| } |
| now := time.Now() |
| intent, ok := a.readAssertionIntent(r, now) |
| if !ok { |
| - a.signinFailed(w) |
| + a.failAssertion(w, assertionIntentInvalid) |
| return |
| } |
| target, ok := a.runtime.consumers[intent.Consumer] |
| if !ok { |
| - a.signinFailed(w) |
| + a.failAssertion(w, assertionConsumer) |
| return |
| } |
| session, ok := sessions.Current(r) |
| if !ok { |
| - a.signinFailed(w) |
| + a.failAssertion(w, assertionSession) |
| return |
| } |
| authTime := session.AuthTime |
| @@ -89,7 +89,7 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) { |
| authTime = session.At |
| } |
| if intent.Subject != "" && intent.Subject != session.Subject { |
| - a.signinFailed(w) |
| + a.failAssertion(w, assertionSubject) |
| return |
| } |
| if intent.MaxAge != 0 && (!sessions.Fresh(session, time.Duration(intent.MaxAge)*time.Second, now) || authTime.After(now)) { |
| @@ -98,12 +98,12 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) { |
| } |
| id, err := assertion.RandomID() |
| if err != nil { |
| - a.signinFailed(w) |
| + a.failAssertion(w, assertionRandom) |
| return |
| } |
| token, err := assertion.Sign(a.runtime.signer, assertion.Claims{Version: 1, Issuer: a.origins.Credential, Audience: target, Subject: session.Subject, KeyID: a.runtime.keyID, ID: id, Nonce: intent.Nonce, IssuedAt: now.Unix(), ExpiresAt: now.Unix() + 120, AuthTime: authTime.Unix()}) |
| if err != nil { |
| - a.signinFailed(w) |
| + a.failAssertion(w, assertionSign) |
| return |
| } |
| http.SetCookie(w, &http.Cookie{Name: assertionIntentCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) |
diff --git a/internal/home/handoff_failure.go b/internal/home/handoff_failure.go| new file mode 100644 |
| index 0000000..555eea6 |
| --- /dev/null |
| +++ b/internal/home/handoff_failure.go |
| @@ -0,0 +1,77 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + |
| + "amadan.net/oficina/home/handoff" |
| +) |
| + |
| +type handoffFailureCode uint8 |
| + |
| +const ( |
| + authorizeAge handoffFailureCode = iota |
| + authorizeRequest |
| + authorizeEncode |
| + authorizeSeal |
| + assertionQuery |
| + assertionIntentInvalid |
| + assertionConsumer |
| + assertionSession |
| + assertionSubject |
| + assertionRandom |
| + assertionSign |
| +) |
| + |
| +// Only this fixed vocabulary reaches logs; no request or error is accepted. |
| +func (a *App) failAssertion(w http.ResponseWriter, code handoffFailureCode) { |
| + stage, reason := "assertion", "unknown" |
| + switch code { |
| + case authorizeAge: |
| + stage, reason = "authorize", "invalid_age" |
| + case authorizeRequest: |
| + stage, reason = "authorize", "invalid_request" |
| + case authorizeEncode: |
| + stage, reason = "authorize", "intent_encode" |
| + case authorizeSeal: |
| + stage, reason = "authorize", "intent_seal" |
| + case assertionQuery: |
| + reason = "unexpected_query" |
| + case assertionIntentInvalid: |
| + reason = "invalid_intent" |
| + case assertionConsumer: |
| + reason = "unknown_consumer" |
| + case assertionSession: |
| + reason = "missing_session" |
| + case assertionSubject: |
| + reason = "subject_mismatch" |
| + case assertionRandom: |
| + reason = "random_id" |
| + case assertionSign: |
| + reason = "signing" |
| + } |
| + a.logger.Warn("home handoff failed", "stage", stage, "reason", reason) |
| + a.renderHandoffFailure(w, http.StatusBadRequest) |
| +} |
| + |
| +func (a *App) failDirectoryHandoff(w http.ResponseWriter, r *http.Request, status int) { |
| + stage := "directory_unknown" |
| + switch r.URL.Path { |
| + case handoff.StartPath: |
| + stage = "directory_start" |
| + case handoff.CallbackPath: |
| + stage = "directory_callback" |
| + case handoff.FinishPath: |
| + stage = "directory_finish" |
| + } |
| + reason := "refused" |
| + if status == http.StatusServiceUnavailable { |
| + reason = "unavailable" |
| + } |
| + a.logger.Warn("home handoff failed", "stage", stage, "reason", reason) |
| + a.renderHandoffFailure(w, status) |
| +} |
| + |
| +func (a *App) renderHandoffFailure(w http.ResponseWriter, status int) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + a.render(w, status, "handoff_failed.html", map[string]any{"HomeURL": a.origins.Directory}) |
| +} |
diff --git a/internal/home/handoff_failure_test.go b/internal/home/handoff_failure_test.go| new file mode 100644 |
| index 0000000..092f393 |
| --- /dev/null |
| +++ b/internal/home/handoff_failure_test.go |
| @@ -0,0 +1,90 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/internal/home/sealtest" |
| +) |
| + |
| +func TestHandoffFailuresHaveFixedPrivateDiagnostics(t *testing.T) { |
| + p := newExchangePair(t) |
| + credential := signInFully(t, p.credential, sealtest.CanaryAddress) |
| + const secret = "private-address@example.invalid" |
| + cases := []struct { |
| + app *App |
| + method, path, origin, stage, reason string |
| + form url.Values |
| + cookies []*http.Cookie |
| + }{ |
| + {p.credential.App, "GET", Origin + "/authorize?consumer=" + secret + "&nonce=" + secret, "", "authorize", "invalid_request", nil, nil}, |
| + {p.credential.App, "GET", Origin + "/authorize/resume?private=" + secret, "", "assertion", "unexpected_query", nil, nil}, |
| + {p.credential.App, "GET", Origin + "/authorize/resume", "", "assertion", "invalid_intent", nil, []*http.Cookie{{Name: assertionIntentCookie, Value: secret}}}, |
| + {p.directory, "GET", directoryOrigin + handoff.StartPath + "?return_to=https://" + secret, "", "directory_start", "refused", nil, nil}, |
| + {p.directory, "POST", directoryOrigin + handoff.CallbackPath, Origin, "directory_callback", "refused", url.Values{"assertion": {secret}}, nil}, |
| + {p.directory, "GET", directoryOrigin + handoff.FinishPath, "", "directory_finish", "refused", nil, []*http.Cookie{{Name: "__Host-oficina-assertion-inbox", Value: secret}}}, |
| + } |
| + for _, tc := range cases { |
| + p.logs.Reset() |
| + if tc.app == p.credential.App { |
| + tc.cookies = append(tc.cookies, credential) |
| + } |
| + response := exchangeRequest(tc.app, tc.method, tc.path, tc.origin, tc.form, tc.cookies...) |
| + if response.Code != 400 || response.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal(response.Code, response.Header()) |
| + } |
| + body := response.Body.String() |
| + if !strings.Contains(body, "Could not finish signing in") || strings.Contains(body, "fifteen minutes") || !strings.Contains(body, `href="`+directoryOrigin+`"`) || strings.Contains(body, secret) { |
| + t.Fatal("wrong handoff failure page", body) |
| + } |
| + log := p.logs.String() |
| + if !strings.Contains(log, "stage="+tc.stage) || !strings.Contains(log, "reason="+tc.reason) || strings.Count(strings.TrimSpace(log), "\n") != 0 { |
| + t.Fatal("wrong diagnostic", log) |
| + } |
| + for _, forbidden := range []string{secret, "nonce=", "consumer=", "https://", "token=", "cookie=", "subject="} { |
| + if strings.Contains(log, forbidden) { |
| + t.Fatal("diagnostic leaked request data", forbidden) |
| + } |
| + } |
| + } |
| + p.logs.Reset() |
| + failedLink := exchangeRequest(p.credential.App, "POST", Origin+linkPath, Origin, url.Values{"token": {secret}}) |
| + if failedLink.Code != 400 || !strings.Contains(failedLink.Body.String(), "Sign-in links work once and expire after fifteen minutes") || strings.Contains(p.logs.String(), "home handoff failed") { |
| + t.Fatal("magic-link failure changed", failedLink.Code) |
| + } |
| +} |
| + |
| +func TestDirectorySubjectConflictLogsStageWithoutSwitchingIdentity(t *testing.T) { |
| + f := newMountedChooser(t) |
| + p := f.p |
| + previous := f.session(t, chooserOther) |
| + credential := signInFully(t, p.credential, sealtest.CanaryAddress) |
| + pending, intent := p.start(t) |
| + token, _ := p.mint(t, intent, credential) |
| + callback := exchangeRequest(p.directory, "POST", directoryOrigin+handoff.CallbackPath, Origin, url.Values{"assertion": {token}}) |
| + if callback.Code != 303 { |
| + t.Fatal(callback.Code) |
| + } |
| + inbox := namedCookie(t, callback, "__Host-oficina-assertion-inbox") |
| + p.logs.Reset() |
| + finish := exchangeRequest(p.directory, "GET", directoryOrigin+handoff.FinishPath, "", nil, pending, inbox, previous) |
| + if finish.Code != 400 || sessionCookie(finish) != nil { |
| + t.Fatal("conflicting identity accepted", finish.Code) |
| + } |
| + log := p.logs.String() |
| + if !strings.Contains(log, "stage=directory_finish reason=refused") { |
| + t.Fatal("missing finish diagnostic", log) |
| + } |
| + for _, secret := range []string{chooserOther, token, pending.Value, inbox.Value, sealtest.CanaryAddress, subjectOf(t, p.credential, credential)} { |
| + if strings.Contains(log, secret) { |
| + t.Fatal("identity or token leaked") |
| + } |
| + } |
| + page := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, previous) |
| + if page.Code != 200 { |
| + t.Fatal("previous session was changed", page.Code) |
| + } |
| +} |
diff --git a/internal/home/templates/handoff_failed.html b/internal/home/templates/handoff_failed.html| new file mode 100644 |
| index 0000000..0017972 |
| --- /dev/null |
| +++ b/internal/home/templates/handoff_failed.html |
| @@ -0,0 +1,6 @@ |
| +{{define "title"}}Could not finish signing in · Oficina{{end}} |
| +{{define "content"}} |
| +<h1>Could not finish signing in</h1> |
| +<p>Return to Home and open the app again.</p> |
| +<p><a href="{{.HomeURL}}">Back to Home</a></p> |
| +{{end}} |