From 2020113a20946837e1c80ff36bc26e4bcc038d05 Mon Sep 17 00:00:00 2001
From: fclairamb
Date: Tue, 27 Oct 2026 07:38:26 +0100
Subject: [PATCH 01/13] chore(specs): security scan findings of 2026-10-27
Co-Authored-By: Claude Opus 5.5
---
...-10-27-01-device-consent-phishing-guard.md | 59 +++++++++++++++++++
1 file changed, 59 insertions(+)
create mode 100644 specs/todos/2026-10-27-01-device-consent-phishing-guard.md
diff --git a/specs/todos/2026-10-27-01-device-consent-phishing-guard.md b/specs/todos/2026-10-27-01-device-consent-phishing-guard.md
new file mode 100644
index 000000000..122660d12
--- /dev/null
+++ b/specs/todos/2026-10-27-01-device-consent-phishing-guard.md
@@ -0,0 +1,59 @@
+---
+model: opus
+effort: medium
+---
+
+# Device-flow consent approves a code anyone can mint and send by link, with no warning
+
+## Problem
+
+Found by the security scan of 2026-10-27 (finding V1).
+
+**Severity: medium.** Anyone can trigger it, with no SolidPing account. It needs one signed-in member of the target instance to follow a link and click Approve.
+
+This is the RFC 8628 §5.4 "remote phishing" risk (device-code phishing). The current consent page has none of the mitigations the RFC asks for.
+
+- `POST /api/v1/auth/device` and `POST /api/v1/auth/device/token` are unauthenticated (`server/internal/app/server.go:829-830`). Anyone can open a request, receive its `user_code` and `verification_uri_complete`, and poll for the token.
+- The requester picks the client name freely (`device_handler.go:43`, `device_service.go:115-148`; the default is "Unknown application", `device_service.go:46`). The attacker can name it after the real CLI.
+- The consent page reads the code from `?user_code=` and looks it up immediately (`web/dash0/src/routes/orgs/$org/account.device.tsx:92-101`). A signed-out visitor is sent through login and brought back with the code intact (`web/dash0/src/lib/login-destination.ts:67`, `:126`).
+- The authorize card shows only the attacker-chosen name, an org picker, the expiry and Approve / Deny (`account.device.tsx:222-241`, copy `device.authorizeDescription` in `web/dash0/src/locales/*/account.json`). Nothing tells the user to approve only a sign-in they started themselves. Nothing shows where or when the request was opened. The server does not record the requester at all (`DeviceConsentInfo`, `device_service.go:102-107`; `models.DeviceAuthRequest`, `server/internal/db/models/device_auth.go:32-48`).
+- On Approve, `RespondToDeviceConsent` mints a 90-day PAT for the approving user in the selected org (`device_service.go:210-262`). The attacker's next poll receives it.
+
+**Attack (no payloads):**
+1. The attacker opens a device request on the target instance and names the client after the real CLI.
+2. The attacker sends a member a link to the real instance's verification page with the code filled in. The pretext can be "finish connecting your CLI" or "approve the integration". The link must be used within the 15-minute request lifetime (`device_service.go:28`).
+3. The member, on the real domain and signed in, sees a normal "Authorize this device" card and clicks Approve.
+4. The attacker's poll returns a PAT carrying the member's role in that org for 90 days. A password reset does not revoke PATs (see T8 in the scan reports).
+
+**Impact:** API access as the victim in the selected org (admin if the victim is an admin) for 90 days. The token stays until someone notices it under Account → Tokens.
+
+## Proposal
+
+The legitimate flow (the CLI opens `verification_uri_complete` in the user's own browser) must stay a one-click approval. The fixes add context and friction against a code that arrived from someone else.
+
+1. **Record the requester.** Store the requester's client IP (from the same trusted-proxy resolution the rate limiter uses) and the user agent on `device_auth_requests` when the request is opened (`device_service.go:115-148`, `device_handler.go:43`). This needs a new migration in both dialects, following `wiki/conventions/migrations.md`: a new `NNN` file, never an edit to a tagged one.
+2. **Show it on the consent page.** Return `requestedAt`, `requesterIp` and a short user-agent summary in `DeviceConsentInfo` (`device_service.go:102-107`). Render them on the authorize card (`account.device.tsx:228-241`), next to the approving user's own IP if that is available cheaply (see To verify).
+3. **Warn explicitly.** Add a destructive-styled alert above Approve, in every locale: "Only approve if you just ran a SolidPing sign-in yourself, on a device in front of you. Never approve a code someone sent you." Mark the client name as self-reported ("calls itself …").
+4. **Notify after approval.** Send the user a "new device token created" email naming the client, the org, the requester IP and the revoke location. If an equivalent PAT-created notification already exists, reuse it (see To verify).
+5. Leave the RFC 8628 wire format, the code TTL and the PAT expiry unchanged.
+
+## Tests
+
+- `server/internal/handlers/auth/device_service_test.go`:
+ - opening a request with a given client IP and user agent, then calling `GetDeviceConsent`, returns `requestedAt`, `requesterIp` and the user-agent summary. This fails before the fix, because the fields don't exist.
+ - positive control: approving still mints the PAT, and a single poll still receives it.
+- `server/internal/handlers/auth/device_handler_test.go`: the requester IP is taken from the trusted resolution, not from a spoofed `X-Forwarded-For` sent by an untrusted peer.
+- Approval sends exactly one notification email to the approving user. Denial and a lost race send none.
+- `web/dash0/e2e/device-authorization.spec.ts`:
+ - the authorize card shows the phishing warning and the requester details. This fails before the fix.
+ - positive control: the existing approve path still completes, and the CLI poll gets its token.
+
+## To verify
+
+- Is there already an email or audit notification when a PAT is created, and can it carry the device context?
+- The helper that gives the trusted client IP (the same one the auth rate limiter uses), so this spec does not add another M10-style spoofable source.
+- Can the consent endpoint cheaply expose the approver's own IP, for a "this request came from a different network" hint? If not, show only the requester IP.
+
+## Open questions
+
+- Should a code that arrives pre-filled from the URL need a second confirmation, such as ticking "I started this sign-in myself" or retyping the code? Recommended: a checkbox only. Retyping defeats the point of `verification_uri_complete` for the legitimate CLI flow.
From 6b6f36ec73fc9f7d4e97a262ea31392f74e67a97 Mon Sep 17 00:00:00 2001
From: fclairamb
Date: Tue, 27 Oct 2026 10:06:29 +0100
Subject: [PATCH 02/13] chore(specs): security scan findings of 2026-10-27
Co-Authored-By: Claude Opus 5.5
---
...6-10-27-02-disable-2fa-attempt-throttle.md | 37 +++++++++++++++++++
1 file changed, 37 insertions(+)
create mode 100644 specs/todos/2026-10-27-02-disable-2fa-attempt-throttle.md
diff --git a/specs/todos/2026-10-27-02-disable-2fa-attempt-throttle.md b/specs/todos/2026-10-27-02-disable-2fa-attempt-throttle.md
new file mode 100644
index 000000000..06d614884
--- /dev/null
+++ b/specs/todos/2026-10-27-02-disable-2fa-attempt-throttle.md
@@ -0,0 +1,37 @@
+---
+model: opus
+effort: low
+---
+
+# `DELETE /auth/2fa` checks the TOTP code with no attempt budget
+
+## Problem
+
+Found by the security scan of 2026-10-27 (finding W1).
+
+**Severity: low.** Exploiting it takes a stolen session access token or an unscoped PAT of a user who has 2FA enabled, and hours of sustained requests. It lets that holder remove the victim's second factor for good.
+
+- Login 2FA is throttled. `Verify2FA` and `Recovery2FA` spend the per-user budget before looking at the code: 5 attempts per 15 minutes (`server/internal/handlers/auth/service.go:4605-4606`, `:4769`, `:4812`). The comment at `:4764-4767` gives the reason: the value of 2FA is that the code is hard to guess.
+- `Disable2FA` (`service.go:4866-4893`) calls `totp.Validate` on the submitted code with no counter at all. The route is `rootAuthProtected.DELETE("/2fa")` (`server/internal/app/server.go:845`), behind `RequireAuth` only. The handler (`handler.go:1448-1472`) adds nothing either.
+- The only limit left is the global per-IP limiter: 1800 requests per minute by default (`server/internal/config/config.go:1899`). `totp.Validate` accepts the previous, current and next 30-second step, so a guess hits about 3 of the 10^6 codes. At the global cap, a single IP finds a valid code in a few hours on average, and spreading the requests over several IPs shortens that.
+- On success the secret and the recovery codes are wiped (`service.go:4884-4892`). From then on the account's password alone is enough to sign in.
+
+**Why it matters:** 2FA is meant to survive a partial compromise. Someone holding only a short-lived session, or a PAT leaked from CI, can turn it into a lasting downgrade. After that, a phished or reused password is enough for an interactive sign-in. The login path explicitly protects the second factor against guessing, and this endpoint quietly does not.
+
+`Confirm2FA` (`service.go:4698-4720`) is not affected: it checks a secret that `Setup2FA` has just returned to the same caller, so guessing it gains nothing.
+
+## Proposal
+
+1. In `Disable2FA` (`server/internal/handlers/auth/service.go:4866`), call `bumpTwoFAAttemptCounter(ctx, userUID)` before reading the user. Do it the same way as `Verify2FA` (`:4769-4776`): spend before comparing, and return `ErrRateLimited` when the budget is exhausted.
+ - Share the login budget (same key): disabling must not open a second allowance next to the login one.
+ - Call `resetTwoFAAttemptCounter` after a successful disable, like `Verify2FA` does at `:4795`.
+2. `handle2FAError` already maps `ErrRateLimited` to 429 `RATE_LIMITED` (`handler.go:1477-1478`). Check that dash0's disable dialog shows that error as a message instead of a generic failure (`web/dash0/src/contexts/AuthContext.tsx`, `web/dash0/src/components/security/`).
+3. Keep the change scoped. Do not add a current-password requirement: federated- and passkey-only users have no password.
+
+## Tests
+
+- `server/internal/handlers/auth/two_fa_attempt_throttle_test.go`, as a new case in `runTwoFAAttemptThrottleSuite` so it runs on both engines:
+ - **Regression (fails before the fix):** for a user with 2FA enabled, 5 wrong codes on `Disable2FA` return `ErrInvalid2FACode`. The 6th call, made with the **valid** current code, returns `ErrRateLimited`, and the user still has `TOTPEnabled = true` with an unchanged secret. Before the fix the 6th call succeeds and disables 2FA.
+ - **Shared budget:** 5 wrong `Verify2FA` attempts, then `Disable2FA` with a valid code, gives `ErrRateLimited`.
+ - **Positive control:** a fresh user with a valid code on the first try disables 2FA, and the counter entry is deleted afterwards.
+- Handler test: the throttled call answers HTTP 429 with code `RATE_LIMITED`.
From 262ca4fa27ce776f7c4895f5b6d4984edc0bdad1 Mon Sep 17 00:00:00 2001
From: Florent Clairambault
Date: Tue, 27 Oct 2026 10:11:33 +0100
Subject: [PATCH 03/13] docs: resolve open questions for device consent guard
Co-Authored-By: Claude Sonnet 5.5
---
specs/todos/2026-10-27-01-device-consent-phishing-guard.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/specs/todos/2026-10-27-01-device-consent-phishing-guard.md b/specs/todos/2026-10-27-01-device-consent-phishing-guard.md
index 122660d12..ceea2f557 100644
--- a/specs/todos/2026-10-27-01-device-consent-phishing-guard.md
+++ b/specs/todos/2026-10-27-01-device-consent-phishing-guard.md
@@ -57,3 +57,7 @@ The legitimate flow (the CLI opens `verification_uri_complete` in the user's own
## Open questions
- Should a code that arrives pre-filled from the URL need a second confirmation, such as ticking "I started this sign-in myself" or retyping the code? Recommended: a checkbox only. Retyping defeats the point of `verification_uri_complete` for the legitimate CLI flow.
+
+## Resolved open questions
+
+- A code pre-filled from the URL needs a "I started this sign-in myself" checkbox only, no retyping. (autopilot: recommended answer)
From 94eaf7e57044bcd06b75ac9febeed3233f18862f Mon Sep 17 00:00:00 2001
From: Florent Clairambault
Date: Tue, 27 Oct 2026 10:17:38 +0100
Subject: [PATCH 04/13] fix(auth): record and expose the device-flow requester,
notify on approval
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Device-code phishing guard (spec 2026-10-27-01, RFC 8628 §5.4):
- device_auth_requests stores requester_ip (trusted-proxy resolution, the
rate limiter's rules, now shared as base.ExtractTrustedClientIP) and
requester_user_agent (migration 031).
- GET /auth/device/consent returns requestedAt, requesterIp,
requesterUserAgent (short summary) and approverIp.
- An approval enqueues device-token-created.html to the approver, naming
the client, org, requester IP and the tokens page.
Co-Authored-By: Claude Opus 5.5
---
server/internal/app/openapi/openapi.yaml | 17 ++-
server/internal/db/models/device_auth.go | 31 ++--
.../migrations/031_v0_43_0.tx.down.sql | 6 +
.../postgres/migrations/031_v0_43_0.tx.up.sql | 24 +++
.../sqlite/migrations/031_v0_43_0.tx.down.sql | 5 +
.../sqlite/migrations/031_v0_43_0.tx.up.sql | 11 ++
server/internal/email/supportreply.go | 2 +
.../email/templates/device-token-created.html | 21 +++
.../internal/handlers/auth/device_handler.go | 15 +-
.../handlers/auth/device_handler_test.go | 58 ++++++++
.../internal/handlers/auth/device_service.go | 135 +++++++++++++++--
.../handlers/auth/device_service_test.go | 138 +++++++++++++++++-
server/internal/handlers/base/base.go | 54 ++++++-
.../handlers/emailpreview/fixtures.go | 1 +
.../handlers/emailpreview/fixtures_account.go | 10 ++
.../emailpreview/locale_account_test.go | 20 ++-
server/internal/i18n/locales/de/account.json | 9 ++
server/internal/i18n/locales/en/account.json | 9 ++
server/internal/i18n/locales/es/account.json | 9 ++
server/internal/i18n/locales/fr/account.json | 9 ++
server/internal/middleware/ratelimit.go | 47 +-----
server/pkg/client/client_generated.go | 19 ++-
22 files changed, 568 insertions(+), 82 deletions(-)
create mode 100644 server/internal/db/postgres/migrations/031_v0_43_0.tx.down.sql
create mode 100644 server/internal/db/postgres/migrations/031_v0_43_0.tx.up.sql
create mode 100644 server/internal/db/sqlite/migrations/031_v0_43_0.tx.down.sql
create mode 100644 server/internal/db/sqlite/migrations/031_v0_43_0.tx.up.sql
create mode 100644 server/internal/email/templates/device-token-created.html
diff --git a/server/internal/app/openapi/openapi.yaml b/server/internal/app/openapi/openapi.yaml
index 76dcc24e3..106249731 100644
--- a/server/internal/app/openapi/openapi.yaml
+++ b/server/internal/app/openapi/openapi.yaml
@@ -10862,7 +10862,7 @@ components:
DeviceConsentInfo:
type: object
- required: [clientName, userCode, status, expiresAt]
+ required: [clientName, userCode, status, expiresAt, requestedAt]
properties:
clientName:
type: string
@@ -10874,6 +10874,21 @@ components:
expiresAt:
type: string
format: date-time
+ requestedAt:
+ type: string
+ format: date-time
+ description: When the device-authorization request was opened.
+ requesterIp:
+ type: string
+ description: >-
+ IP that opened the request (trusted-proxy resolution). Absent for
+ requests opened before it was recorded.
+ requesterUserAgent:
+ type: string
+ description: Short summary of the requesting client's User-Agent. Absent when none was sent.
+ approverIp:
+ type: string
+ description: IP of the caller of this endpoint, for a "different network" hint.
DeviceConsentRequest:
type: object
diff --git a/server/internal/db/models/device_auth.go b/server/internal/db/models/device_auth.go
index cb11e1413..f4bafd8af 100644
--- a/server/internal/db/models/device_auth.go
+++ b/server/internal/db/models/device_auth.go
@@ -32,19 +32,24 @@ const (
type DeviceAuthRequest struct {
bun.BaseModel `bun:"table:device_auth_requests,alias:device_auth_request"`
- UID string `bun:"uid,pk,type:varchar(36)"`
- DeviceCode string `bun:"device_code,notnull"`
- UserCode string `bun:"user_code,notnull"`
- ClientName string `bun:"client_name,notnull"`
- Status string `bun:"status,notnull"`
- OrganizationUID *string `bun:"organization_uid"`
- UserUID *string `bun:"user_uid"`
- TokenUID *string `bun:"token_uid"`
- TokenValue *string `bun:"token_value"`
- LastPolledAt *time.Time `bun:"last_polled_at"`
- ExpiresAt time.Time `bun:"expires_at,notnull"`
- CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"`
- UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp"`
+ UID string `bun:"uid,pk,type:varchar(36)"`
+ DeviceCode string `bun:"device_code,notnull"`
+ UserCode string `bun:"user_code,notnull"`
+ ClientName string `bun:"client_name,notnull"`
+ // RequesterIP and RequesterUserAgent describe the client that opened the
+ // request, shown on the consent page (spec 2026-10-27-01). Nil on rows
+ // opened before they were recorded.
+ RequesterIP *string `bun:"requester_ip"`
+ RequesterUserAgent *string `bun:"requester_user_agent"`
+ Status string `bun:"status,notnull"`
+ OrganizationUID *string `bun:"organization_uid"`
+ UserUID *string `bun:"user_uid"`
+ TokenUID *string `bun:"token_uid"`
+ TokenValue *string `bun:"token_value"`
+ LastPolledAt *time.Time `bun:"last_polled_at"`
+ ExpiresAt time.Time `bun:"expires_at,notnull"`
+ CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"`
+ UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp"`
}
// NewDeviceAuthRequest builds a pending request with a generated UID.
diff --git a/server/internal/db/postgres/migrations/031_v0_43_0.tx.down.sql b/server/internal/db/postgres/migrations/031_v0_43_0.tx.down.sql
new file mode 100644
index 000000000..1a88d79fb
--- /dev/null
+++ b/server/internal/db/postgres/migrations/031_v0_43_0.tx.down.sql
@@ -0,0 +1,6 @@
+-- v0.43.0 — Postgres rollback (parity only; downs are never run in production).
+
+-- SECTION: device-requester
+alter table device_auth_requests drop column if exists requester_user_agent;
+--bun:split
+alter table device_auth_requests drop column if exists requester_ip;
diff --git a/server/internal/db/postgres/migrations/031_v0_43_0.tx.up.sql b/server/internal/db/postgres/migrations/031_v0_43_0.tx.up.sql
new file mode 100644
index 000000000..5cc08365f
--- /dev/null
+++ b/server/internal/db/postgres/migrations/031_v0_43_0.tx.up.sql
@@ -0,0 +1,24 @@
+-- v0.43.0 — Postgres migration.
+--
+-- Frozen once tagged, like every migration: a later change goes in 032.
+--
+-- SECTION: device-requester device_auth_requests.requester_ip / requester_user_agent
+
+-- ==========================================================================
+-- SECTION: device-requester (spec 2026-10-27-01)
+-- ==========================================================================
+-- Who opened a device-authorization request, shown on the consent page so a
+-- member can tell a request they started from one someone sent them.
+alter table device_auth_requests add column if not exists requester_ip text;
+
+--bun:split
+
+alter table device_auth_requests add column if not exists requester_user_agent text;
+
+--bun:split
+
+comment on column device_auth_requests.requester_ip is 'Client IP that opened the request, resolved with the trusted-proxy rules of the rate limiter. NULL for rows opened before v0.43.0.';
+
+--bun:split
+
+comment on column device_auth_requests.requester_user_agent is 'User-Agent header of the client that opened the request, truncated. NULL when absent.';
diff --git a/server/internal/db/sqlite/migrations/031_v0_43_0.tx.down.sql b/server/internal/db/sqlite/migrations/031_v0_43_0.tx.down.sql
new file mode 100644
index 000000000..5d8f2233c
--- /dev/null
+++ b/server/internal/db/sqlite/migrations/031_v0_43_0.tx.down.sql
@@ -0,0 +1,5 @@
+-- v0.43.0 — SQLite rollback (parity only; downs are never run in production).
+
+-- SECTION: device-requester
+alter table device_auth_requests drop column requester_user_agent;
+alter table device_auth_requests drop column requester_ip;
diff --git a/server/internal/db/sqlite/migrations/031_v0_43_0.tx.up.sql b/server/internal/db/sqlite/migrations/031_v0_43_0.tx.up.sql
new file mode 100644
index 000000000..792744878
--- /dev/null
+++ b/server/internal/db/sqlite/migrations/031_v0_43_0.tx.up.sql
@@ -0,0 +1,11 @@
+-- v0.43.0 — SQLite twin of the Postgres 031_v0_43_0 migration.
+--
+-- Frozen once tagged, like every migration: a later change goes in 032.
+
+-- SECTION: device-requester (spec 2026-10-27-01)
+-- device_auth_requests.requester_ip: client IP that opened the request
+-- (trusted-proxy resolution); NULL for rows opened before v0.43.0.
+alter table device_auth_requests add column requester_ip text;
+-- device_auth_requests.requester_user_agent: User-Agent of that client,
+-- truncated; NULL when absent.
+alter table device_auth_requests add column requester_user_agent text;
diff --git a/server/internal/email/supportreply.go b/server/internal/email/supportreply.go
index 3b5c93e4a..eecfa4eeb 100644
--- a/server/internal/email/supportreply.go
+++ b/server/internal/email/supportreply.go
@@ -51,6 +51,7 @@ const (
TemplateIncidentDegradedCreated = "incident-degraded-created.html"
TemplateIncidentDegradedResolv = "incident-degraded-resolved.html"
TemplateEmailChanged = "email-changed.html"
+ TemplateDeviceTokenCreated = "device-token-created.html"
TemplateIncidentAcknowledged = "incident-acknowledged.html"
TemplateIncidentUnacknowledged = "incident-unacknowledged.html"
TemplateIncidentComment = "incident-comment.html"
@@ -141,6 +142,7 @@ var supportReplyableTemplates = map[string]bool{
TemplateInvitation: false,
TemplatePasswordChanged: false,
TemplateEmailChanged: false,
+ TemplateDeviceTokenCreated: false,
TemplatePasswordReset: false,
TemplatePasswordResetSSO: false,
TemplateRegistration: false,
diff --git a/server/internal/email/templates/device-token-created.html b/server/internal/email/templates/device-token-created.html
new file mode 100644
index 000000000..7b2e8175b
--- /dev/null
+++ b/server/internal/email/templates/device-token-created.html
@@ -0,0 +1,21 @@
+{{define "subject"}}{{t "account.deviceTokenCreated.subject"}}{{end}}
+{{define "preheader"}}{{t "account.deviceTokenCreated.preheader" "client" .ClientName "org" .OrgName}}{{end}}
+{{template "base.html" .}}
+{{define "content"}}
+
+{{end}}
+{{define "text"}}{{t "account.deviceTokenCreated.title"}}
+
+{{t "account.deviceTokenCreated.body" "client" .ClientName "org" .OrgName "date" (date .CreatedAt)}}
+{{if .RequesterIP}}
+{{t "account.deviceTokenCreated.requester" "ip" .RequesterIP}}
+{{end}}
+{{t "account.deviceTokenCreated.revoke"}} {{.TokensURL}}
+
+{{t "account.securityNotice.warning"}}
+{{end}}
diff --git a/server/internal/handlers/auth/device_handler.go b/server/internal/handlers/auth/device_handler.go
index 10f2e3139..9191ed446 100644
--- a/server/internal/handlers/auth/device_handler.go
+++ b/server/internal/handlers/auth/device_handler.go
@@ -40,7 +40,10 @@ func (h *Handler) StartDeviceAuthorization(writer http.ResponseWriter, req *http
body = DeviceAuthorizationRequestBody{}
}
- resp, err := h.svc.StartDeviceAuthorization(req.Context(), body.ClientName)
+ resp, err := h.svc.StartDeviceAuthorization(req.Context(), body.ClientName, DeviceRequester{
+ IP: h.trustedClientIP(req),
+ UserAgent: req.UserAgent(),
+ })
if err != nil {
if errors.Is(err, ErrDeviceClientNameTooLong) {
return h.WriteValidationError(writer, "Validation error", []base.ValidationErrorField{
@@ -153,9 +156,19 @@ func (h *Handler) GetDeviceConsent(writer http.ResponseWriter, req *http.Request
"Device authorization request not found or expired")
}
+ info.ApproverIP = h.trustedClientIP(req)
+
return h.WriteJSON(writer, http.StatusOK, info)
}
+// trustedClientIP resolves the caller's IP with the rate limiter's
+// trusted-proxy rules (read live from the config), so a forwarding header
+// sent by an untrusted peer cannot dress up the requester shown on the
+// consent page.
+func (h *Handler) trustedClientIP(req *http.Request) string {
+ return base.ExtractTrustedClientIP(req, h.svc.fullCfg.Server.RateLimiting.TrustedProxies)
+}
+
// DeviceConsentRequest is the body of POST /auth/device/consent. Org is the
// slug the approving user picked on the consent page — the minted PAT is bound
// to it, NOT implicitly to the session's org. Empty means "the session's org".
diff --git a/server/internal/handlers/auth/device_handler_test.go b/server/internal/handlers/auth/device_handler_test.go
index a6810489e..80b47eb66 100644
--- a/server/internal/handlers/auth/device_handler_test.go
+++ b/server/internal/handlers/auth/device_handler_test.go
@@ -232,3 +232,61 @@ func postDeviceAuthed(
return rec
}
+
+// TestDeviceRequesterIPIsTrusted pins spec 2026-10-27-01: the requester IP
+// shown on the consent page comes from the rate limiter's trusted-proxy
+// resolution. An untrusted peer cannot dress it up with X-Forwarded-For.
+func TestDeviceRequesterIPIsTrusted(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ trustedProxies int
+ wantRequester string
+ }{
+ {name: "no trusted proxy ignores a spoofed header", trustedProxies: 0, wantRequester: "192.0.2.10"},
+ {name: "one trusted proxy reads its hop", trustedProxies: 1, wantRequester: "203.0.113.7"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ svc, dbSvc, ctx := setupAuthTestServiceWithConfig(t, "https://ping.example.com")
+ svc.fullCfg.Server.RateLimiting.TrustedProxies = tc.trustedProxies
+ handler := NewHandler(svc, &config.Config{})
+
+ req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/device",
+ strings.NewReader(`{"clientName":"sp CLI"}`))
+ req.RemoteAddr = "192.0.2.10:5555"
+ req.Header.Set("X-Forwarded-For", "203.0.113.7")
+ req.Header.Set("User-Agent", "sp-cli/1.4.0 (linux; amd64)")
+ rec := httptest.NewRecorder()
+ r.NoError(handler.StartDeviceAuthorization(rec, req))
+ r.Equal(http.StatusOK, rec.Code)
+
+ started := map[string]any{}
+ r.NoError(json.Unmarshal(rec.Body.Bytes(), &started))
+ userCode, ok := started["user_code"].(string)
+ r.True(ok)
+
+ _, _, claims := deviceTestUser(ctx, t, dbSvc, "device-ip", "device-ip@example.com")
+ authCtx := context.WithValue(ctx, base.ContextKeyClaims, claims)
+
+ req = httptest.NewRequestWithContext(authCtx, http.MethodGet,
+ "/api/v1/auth/device/consent?userCode="+userCode, http.NoBody)
+ req.RemoteAddr = "198.51.100.20:4444"
+ rec = httptest.NewRecorder()
+ r.NoError(handler.GetDeviceConsent(rec, req))
+ r.Equal(http.StatusOK, rec.Code)
+
+ info := map[string]any{}
+ r.NoError(json.Unmarshal(rec.Body.Bytes(), &info))
+ r.Equal(tc.wantRequester, info["requesterIp"])
+ r.Equal("sp-cli/1.4.0 (linux; amd64)", info["requesterUserAgent"])
+ r.Equal("198.51.100.20", info["approverIp"], "the approver's own IP, for the different-network hint")
+ r.NotEmpty(info["requestedAt"])
+ })
+ }
+}
diff --git a/server/internal/handlers/auth/device_service.go b/server/internal/handlers/auth/device_service.go
index e2513b316..d4131d009 100644
--- a/server/internal/handlers/auth/device_service.go
+++ b/server/internal/handlers/auth/device_service.go
@@ -12,6 +12,8 @@ import (
"github.com/fclairamb/solidping/server/internal/config"
"github.com/fclairamb/solidping/server/internal/db/models"
+ "github.com/fclairamb/solidping/server/internal/email"
+ "github.com/fclairamb/solidping/server/internal/i18n/recipient"
)
// OAuth 2.0 Device Authorization Grant (RFC 8628) parameters.
@@ -42,6 +44,10 @@ const (
deviceCodeBytes = 32
// deviceClientNameMaxLength caps the consent-page label.
deviceClientNameMaxLength = 200
+ // deviceUserAgentMaxLength caps the stored requester User-Agent.
+ deviceUserAgentMaxLength = 512
+ // deviceUserAgentSummaryLength caps the User-Agent shown on the consent page.
+ deviceUserAgentSummaryLength = 120
// deviceDefaultClientName labels a request that sent no client name.
deviceDefaultClientName = "Unknown application"
// deviceVerificationPath is the dashboard route serving the consent page.
@@ -100,11 +106,29 @@ type DeviceAuthorizationResponse struct {
// DeviceConsentInfo is the public detail of a device-authorization request,
// safe to render on the (authenticated) consent page. It never carries the
// device code or the minted token.
+//
+// RequestedAt, RequesterIP and RequesterUserAgent describe who opened the
+// request, so a member can tell a sign-in they started from a code someone
+// sent them (RFC 8628 §5.4, spec 2026-10-27-01). ApproverIP is the IP of the
+// consent page's own caller, for a "different network" hint; the handler sets
+// it.
type DeviceConsentInfo struct {
- ClientName string `json:"clientName"`
- UserCode string `json:"userCode"`
- Status string `json:"status"`
- ExpiresAt time.Time `json:"expiresAt"`
+ ClientName string `json:"clientName"`
+ UserCode string `json:"userCode"`
+ Status string `json:"status"`
+ ExpiresAt time.Time `json:"expiresAt"`
+ RequestedAt time.Time `json:"requestedAt"`
+ RequesterIP string `json:"requesterIp,omitempty"`
+ RequesterUserAgent string `json:"requesterUserAgent,omitempty"`
+ ApproverIP string `json:"approverIp,omitempty"`
+}
+
+// DeviceRequester describes the client opening a device-authorization
+// request. IP must come from the trusted-proxy resolution
+// (base.ExtractTrustedClientIP), never from a raw forwarding header.
+type DeviceRequester struct {
+ IP string
+ UserAgent string
}
// StartDeviceAuthorization opens a new device-authorization request. It is
@@ -112,7 +136,7 @@ type DeviceConsentInfo struct {
// nothing until a logged-in user approves it and it is bound to the org that
// user selects.
func (s *Service) StartDeviceAuthorization(
- ctx context.Context, clientName string,
+ ctx context.Context, clientName string, requester DeviceRequester,
) (*DeviceAuthorizationResponse, error) {
clientName = strings.TrimSpace(clientName)
if clientName == "" {
@@ -146,6 +170,9 @@ func (s *Service) StartDeviceAuthorization(
}
req := models.NewDeviceAuthRequest(clientName, deviceCode, userCode, expiresAt)
+ req.RequesterIP = optionalString(strings.TrimSpace(requester.IP))
+ req.RequesterUserAgent = optionalString(truncateRunes(strings.TrimSpace(requester.UserAgent),
+ deviceUserAgentMaxLength, ""))
if createErr := s.db.CreateDeviceAuthRequest(ctx, req); createErr != nil {
// Assume a user-code collision and retry with a fresh pair; a
// genuine storage failure loses on the last attempt below.
@@ -190,12 +217,23 @@ func (s *Service) GetDeviceConsent(ctx context.Context, rawUserCode string) (*De
return nil, ErrDeviceAuthNotFound
}
- return &DeviceConsentInfo{
- ClientName: req.ClientName,
- UserCode: FormatDeviceUserCode(req.UserCode),
- Status: req.Status,
- ExpiresAt: req.ExpiresAt,
- }, nil
+ info := &DeviceConsentInfo{
+ ClientName: req.ClientName,
+ UserCode: FormatDeviceUserCode(req.UserCode),
+ Status: req.Status,
+ ExpiresAt: req.ExpiresAt,
+ RequestedAt: req.CreatedAt,
+ }
+
+ if req.RequesterIP != nil {
+ info.RequesterIP = *req.RequesterIP
+ }
+
+ if req.RequesterUserAgent != nil {
+ info.RequesterUserAgent = summarizeUserAgent(*req.RequesterUserAgent)
+ }
+
+ return info, nil
}
// RespondToDeviceConsent approves or denies a pending request on behalf of the
@@ -253,13 +291,57 @@ func (s *Service) RespondToDeviceConsent(
return err
}
- return s.publishDeviceDecision(ctx, req.UID, &models.DeviceAuthResolution{
+ if err := s.publishDeviceDecision(ctx, req.UID, &models.DeviceAuthResolution{
Status: models.DeviceAuthStatusApproved,
UserUID: claims.UserUID,
OrganizationUID: org.UID,
TokenUID: pat.UID,
TokenValue: pat.Token,
- }, pat)
+ }, pat); err != nil {
+ return err
+ }
+
+ s.notifyDeviceTokenCreated(ctx, claims.UserUID, org, req)
+
+ return nil
+}
+
+// notifyDeviceTokenCreated emails the approving user a record of the token
+// the device flow just minted, so an approval obtained by phishing does not
+// go unnoticed for 90 days. Best effort: a failure is logged, never returned,
+// because the decision is already published.
+func (s *Service) notifyDeviceTokenCreated(
+ ctx context.Context, userUID string, org *models.Organization, req *models.DeviceAuthRequest,
+) {
+ user, err := s.db.GetUser(ctx, userUID)
+ if err != nil {
+ slog.WarnContext(ctx, "failed to load user for device token notice", "error", err)
+
+ return
+ }
+
+ requesterIP := ""
+ if req.RequesterIP != nil {
+ requesterIP = *req.RequesterIP
+ }
+
+ orgName := org.Name
+ if orgName == "" {
+ orgName = org.Slug
+ }
+
+ tokensURL := fmt.Sprintf("%s%s/orgs/%s/account/tokens",
+ strings.TrimRight(s.fullCfg.Server.BaseURL, "/"), config.DashboardBasePath, org.Slug)
+
+ s.enqueueEmail(ctx, "", user.Email, email.TemplateDeviceTokenCreated, recipient.ForUser(user),
+ map[string]any{
+ "ClientName": req.ClientName,
+ "OrgName": orgName,
+ "RequesterIP": requesterIP,
+ "CreatedAt": time.Now().UTC(),
+ "TokensURL": tokensURL,
+ },
+ )
}
// publishDeviceDecision runs the compare-and-set and, when it loses after a PAT
@@ -343,6 +425,33 @@ func (s *Service) PollDeviceToken(ctx context.Context, deviceCode string) (strin
}
}
+// optionalString maps "" to nil for a nullable column.
+func optionalString(value string) *string {
+ if value == "" {
+ return nil
+ }
+
+ return &value
+}
+
+// truncateRunes cuts value to at most maxRunes runes, appending suffix when it
+// had to cut.
+func truncateRunes(value string, maxRunes int, suffix string) string {
+ runes := []rune(value)
+ if len(runes) <= maxRunes {
+ return value
+ }
+
+ return string(runes[:maxRunes]) + suffix
+}
+
+// summarizeUserAgent turns a stored User-Agent into the short, single-line
+// label the consent page shows. It is requester-supplied text, rendered as
+// plain text by the dashboard.
+func summarizeUserAgent(userAgent string) string {
+ return truncateRunes(strings.Join(strings.Fields(userAgent), " "), deviceUserAgentSummaryLength, "…")
+}
+
// generateDeviceCode returns the client's secret device code as hex.
func generateDeviceCode() (string, error) {
buf := make([]byte, deviceCodeBytes)
diff --git a/server/internal/handlers/auth/device_service_test.go b/server/internal/handlers/auth/device_service_test.go
index db6ce0f00..25d47bc9e 100644
--- a/server/internal/handlers/auth/device_service_test.go
+++ b/server/internal/handlers/auth/device_service_test.go
@@ -2,13 +2,16 @@ package auth
import (
"context"
+ "strings"
"testing"
"time"
"github.com/stretchr/testify/require"
+ "github.com/fclairamb/solidping/server/internal/config"
"github.com/fclairamb/solidping/server/internal/db"
"github.com/fclairamb/solidping/server/internal/db/models"
+ "github.com/fclairamb/solidping/server/internal/email"
)
// deviceTestUser wires an org + user + membership and returns claims for that
@@ -86,7 +89,7 @@ func TestDeviceAuthorizationStart(t *testing.T) {
r := require.New(t)
- resp, err := svc.StartDeviceAuthorization(ctx, tc.clientName)
+ resp, err := svc.StartDeviceAuthorization(ctx, tc.clientName, DeviceRequester{})
if tc.wantErr != nil {
r.ErrorIs(err, tc.wantErr)
@@ -121,7 +124,7 @@ func TestDeviceAuthLifecycle(t *testing.T) {
org, user, claims := deviceTestUser(ctx, t, dbSvc, "device-life", "device-life@example.com")
- start, err := svc.StartDeviceAuthorization(ctx, "sp CLI on laptop")
+ start, err := svc.StartDeviceAuthorization(ctx, "sp CLI on laptop", DeviceRequester{})
r.NoError(err)
// Poll before approval → authorization_pending, and no token leaks.
@@ -165,7 +168,7 @@ func TestDeviceAuthDeny(t *testing.T) {
org, user, claims := deviceTestUser(ctx, t, dbSvc, "device-deny", "device-deny@example.com")
- start, err := svc.StartDeviceAuthorization(ctx, "sp CLI")
+ start, err := svc.StartDeviceAuthorization(ctx, "sp CLI", DeviceRequester{})
r.NoError(err)
r.NoError(svc.RespondToDeviceConsent(ctx, claims, start.UserCode, org.Slug, false))
@@ -252,7 +255,7 @@ func TestDeviceAuthSlowDown(t *testing.T) {
org, _, claims := deviceTestUser(ctx, t, dbSvc, "device-slow", "device-slow@example.com")
- start, err := svc.StartDeviceAuthorization(ctx, "sp CLI")
+ start, err := svc.StartDeviceAuthorization(ctx, "sp CLI", DeviceRequester{})
r.NoError(err)
_, err = svc.PollDeviceToken(ctx, start.DeviceCode)
@@ -309,7 +312,7 @@ func TestDeviceAuthOrgBinding(t *testing.T) {
r := require.New(t)
- start, err := svc.StartDeviceAuthorization(ctx, "sp CLI")
+ start, err := svc.StartDeviceAuthorization(ctx, "sp CLI", DeviceRequester{})
r.NoError(err)
err = svc.RespondToDeviceConsent(ctx, claims, start.UserCode, tc.orgInput, true)
@@ -389,3 +392,128 @@ func TestDeviceUserCodeFormatting(t *testing.T) {
}
}
}
+
+// TestDeviceConsentShowsRequester pins spec 2026-10-27-01: the consent page
+// learns who opened the request (where and when, and with which client), so a
+// member can tell a sign-in they started from a code someone sent them.
+func TestDeviceConsentShowsRequester(t *testing.T) {
+ t.Parallel()
+
+ svc, _, ctx := setupAuthTestServiceWithConfig(t, "https://ping.example.com")
+ r := require.New(t)
+
+ before := time.Now().Add(-time.Second)
+
+ start, err := svc.StartDeviceAuthorization(ctx, "sp CLI", DeviceRequester{
+ IP: "203.0.113.7",
+ UserAgent: "sp-cli/1.4.0 (darwin; arm64)\n" + strings.Repeat("x", 300),
+ })
+ r.NoError(err)
+
+ info, err := svc.GetDeviceConsent(ctx, start.UserCode)
+ r.NoError(err)
+ r.Equal("203.0.113.7", info.RequesterIP)
+ r.True(strings.HasPrefix(info.RequesterUserAgent, "sp-cli/1.4.0 (darwin; arm64) xxx"),
+ "the summary collapses whitespace: %q", info.RequesterUserAgent)
+ r.LessOrEqual(len([]rune(info.RequesterUserAgent)), deviceUserAgentSummaryLength+1, "the summary is short")
+ r.WithinDuration(time.Now(), info.RequestedAt, time.Minute)
+ r.True(info.RequestedAt.After(before))
+
+ // A request opened without any requester detail still renders.
+ bare, err := svc.StartDeviceAuthorization(ctx, "sp CLI", DeviceRequester{})
+ r.NoError(err)
+
+ info, err = svc.GetDeviceConsent(ctx, bare.UserCode)
+ r.NoError(err)
+ r.Empty(info.RequesterIP)
+ r.Empty(info.RequesterUserAgent)
+}
+
+// TestDeviceApprovalNotifiesUser: approval emails the approving user exactly
+// once, naming the client, the org, the requester IP and where to revoke.
+// Denial and a lost race send nothing. The PAT is still delivered (positive
+// control: the notice must not break the flow).
+func TestDeviceApprovalNotifiesUser(t *testing.T) {
+ t.Parallel()
+
+ r := require.New(t)
+
+ fullCfg := &config.Config{
+ Server: config.ServerConfig{BaseURL: "https://ping.example.com"},
+ Auth: config.AuthConfig{
+ JWTSecret: "test-jwt-secret",
+ AccessTokenExpiry: time.Hour,
+ RefreshTokenExpiry: 7 * 24 * time.Hour,
+ },
+ }
+ svc, dbSvc, ctx := setupAuthTestServiceWithJobs(t, fullCfg)
+
+ org, user, claims := deviceTestUser(ctx, t, dbSvc, "device-notice", "device-notice@example.com")
+
+ // Denial: no notice.
+ denied, err := svc.StartDeviceAuthorization(ctx, "sp CLI", DeviceRequester{IP: "198.51.100.1"})
+ r.NoError(err)
+ r.NoError(svc.RespondToDeviceConsent(ctx, claims, denied.UserCode, org.Slug, false))
+ r.Empty(emailJobRecipients(t, ctx, dbSvc, email.TemplateDeviceTokenCreated), "a denial sends no notice")
+
+ // Approval: exactly one notice, to the approver.
+ start, err := svc.StartDeviceAuthorization(ctx, "sp CLI on laptop", DeviceRequester{IP: "203.0.113.7"})
+ r.NoError(err)
+ r.NoError(svc.RespondToDeviceConsent(ctx, claims, start.UserCode, org.Slug, true))
+ r.Equal([]string{user.Email}, emailJobRecipients(t, ctx, dbSvc, email.TemplateDeviceTokenCreated))
+
+ tmpl, data := latestEmailJobTemplateData(t, ctx, dbSvc)
+ r.Equal(email.TemplateDeviceTokenCreated, tmpl)
+ r.Equal("sp CLI on laptop", data["ClientName"])
+ r.Equal(org.Name, data["OrgName"])
+ r.Equal("203.0.113.7", data["RequesterIP"])
+ r.Equal("https://ping.example.com/d/orgs/"+org.Slug+"/account/tokens", data["TokensURL"])
+
+ // A second decision on the same code loses: no extra notice.
+ r.Error(svc.RespondToDeviceConsent(ctx, claims, start.UserCode, org.Slug, true))
+ r.Len(emailJobRecipients(t, ctx, dbSvc, email.TemplateDeviceTokenCreated), 1)
+
+ // Positive control: one poll still receives the PAT.
+ clearDevicePollStamp(ctx, t, dbSvc, start.DeviceCode)
+ token, err := svc.PollDeviceToken(ctx, start.DeviceCode)
+ r.NoError(err)
+ r.NotEmpty(token)
+}
+
+// TestDeviceLostRaceSendsNoNotice: when the compare-and-set loses after the
+// PAT was minted, the PAT is revoked and no notice goes out.
+func TestDeviceLostRaceSendsNoNotice(t *testing.T) {
+ t.Parallel()
+
+ r := require.New(t)
+
+ fullCfg := &config.Config{
+ Server: config.ServerConfig{BaseURL: "https://ping.example.com"},
+ Auth: config.AuthConfig{JWTSecret: "test-jwt-secret", AccessTokenExpiry: time.Hour},
+ }
+ svc, dbSvc, ctx := setupAuthTestServiceWithJobs(t, fullCfg)
+
+ org, user, claims := deviceTestUser(ctx, t, dbSvc, "device-race", "device-race@example.com")
+
+ start, err := svc.StartDeviceAuthorization(ctx, "sp CLI", DeviceRequester{})
+ r.NoError(err)
+
+ stored, err := dbSvc.GetDeviceAuthRequestByDeviceCode(ctx, start.DeviceCode)
+ r.NoError(err)
+
+ // Another decision lands between the lookup and the compare-and-set.
+ won, err := dbSvc.ResolveDeviceAuthRequest(ctx, stored.UID, &models.DeviceAuthResolution{
+ Status: models.DeviceAuthStatusDenied, UserUID: user.UID,
+ })
+ r.NoError(err)
+ r.True(won)
+
+ pat, err := svc.CreatePAT(ctx, org.Slug, claims.UserUID, CreateTokenRequest{Name: "race"})
+ r.NoError(err)
+ r.ErrorIs(svc.publishDeviceDecision(ctx, stored.UID, &models.DeviceAuthResolution{
+ Status: models.DeviceAuthStatusApproved, UserUID: user.UID, OrganizationUID: org.UID,
+ TokenUID: pat.UID, TokenValue: pat.Token,
+ }, pat), ErrDeviceAuthAlreadyResolved)
+
+ r.Empty(emailJobRecipients(t, ctx, dbSvc, email.TemplateDeviceTokenCreated), "a lost race sends no notice")
+}
diff --git a/server/internal/handlers/base/base.go b/server/internal/handlers/base/base.go
index 9a52eab23..963a951ed 100644
--- a/server/internal/handlers/base/base.go
+++ b/server/internal/handlers/base/base.go
@@ -4,6 +4,7 @@ package base
import (
"encoding/json"
"fmt"
+ "net"
"net/http"
"runtime"
"strings"
@@ -304,8 +305,8 @@ func (h *HandlerBase) WriteInternalError(w http.ResponseWriter, r *http.Request,
//
// This is NOT suitable for security-sensitive decisions (e.g. rate limiting)
// since any client can spoof these headers unless a trusted proxy strips or
-// overwrites them first. See middleware/ratelimit.go's extractIP for the
-// proxy-trust-gated variant used there.
+// overwrites them first. See the
+// proxy-trust-gated variant, ExtractTrustedClientIP.
func ExtractRemoteAddr(req *http.Request) string {
// Try X-Forwarded-For header first (common in reverse proxy setups)
if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
@@ -326,3 +327,52 @@ func ExtractRemoteAddr(req *http.Request) string {
return "unknown"
}
+
+// ExtractIPFromXFF parses X-Forwarded-For and returns the real client IP.
+// Each proxy hop appends the peer address it accepted the connection from,
+// so with N trusted hops the client is the Nth entry from the right —
+// parts[len(parts)-trustedProxies] (0-indexed). When the header carries
+// fewer entries than trusted hops (e.g. a single-entry header behind one
+// proxy that overwrote a client-supplied value), the left-most entry is the
+// best available answer, so the index is clamped to 0.
+func ExtractIPFromXFF(xff string, trustedProxies int) string {
+ if trustedProxies <= 0 {
+ return ""
+ }
+ parts := strings.Split(xff, ",")
+ idx := len(parts) - trustedProxies
+ if idx < 0 {
+ idx = 0
+ }
+ return strings.TrimSpace(parts[idx])
+}
+
+// ExtractTrustedClientIP returns the client IP, respecting the configured
+// TrustedProxies count (config.RateLimitConfig). Unlike ExtractRemoteAddr it
+// never trusts a forwarding header sent by an untrusted peer, so it is the one
+// to use for anything shown to a user as a security signal.
+//
+// Trust model: setting TrustedProxies > 0 asserts that the deployment sits
+// behind that many proxy hops which *strip or overwrite* client-supplied
+// X-Forwarded-For / X-Real-IP headers (traefik and most managed ingresses
+// do; verify before enabling behind a bare ALB or default nginx). X-Real-IP
+// is only consulted when X-Forwarded-For is absent entirely — a present but
+// unparsable XFF falls through to RemoteAddr, never to the equally
+// client-controllable X-Real-IP.
+func ExtractTrustedClientIP(req *http.Request, trustedProxies int) string {
+ if trustedProxies > 0 {
+ if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
+ if ip := ExtractIPFromXFF(xff, trustedProxies); ip != "" {
+ return ip
+ }
+ } else if xri := req.Header.Get("X-Real-IP"); xri != "" {
+ return strings.TrimSpace(xri)
+ }
+ }
+
+ host, _, err := net.SplitHostPort(req.RemoteAddr)
+ if err != nil {
+ return req.RemoteAddr
+ }
+ return host
+}
diff --git a/server/internal/handlers/emailpreview/fixtures.go b/server/internal/handlers/emailpreview/fixtures.go
index 37aa1c9b9..5d7e617e2 100644
--- a/server/internal/handlers/emailpreview/fixtures.go
+++ b/server/internal/handlers/emailpreview/fixtures.go
@@ -81,6 +81,7 @@ var fixtureBuilders = map[string]func(i18n.Locale) map[string]any{
"welcome.html": welcomeFixture,
"password-changed.html": passwordChangedFixture,
"email-changed.html": emailChangedFixture,
+ "device-token-created.html": deviceTokenCreatedFixture,
"membership-request-new.html": membershipRequestNewFixture,
"membership-request-decision.html": membershipRequestDecisionFixture,
"uptime-report.html": uptimeReportFixture,
diff --git a/server/internal/handlers/emailpreview/fixtures_account.go b/server/internal/handlers/emailpreview/fixtures_account.go
index ce8efe002..81699015b 100644
--- a/server/internal/handlers/emailpreview/fixtures_account.go
+++ b/server/internal/handlers/emailpreview/fixtures_account.go
@@ -60,6 +60,16 @@ func emailChangedFixture(_ i18n.Locale) map[string]any {
}
}
+func deviceTokenCreatedFixture(_ i18n.Locale) map[string]any {
+ return map[string]any{
+ "ClientName": "sp CLI on laptop",
+ keyOrgName: fixtureOrgName,
+ "RequesterIP": "203.0.113.7",
+ "CreatedAt": fixtureChangedAt(),
+ "TokensURL": fixtureDashboardURL + "/orgs/acme/account/tokens",
+ }
+}
+
func invitationFixture(loc i18n.Locale) map[string]any {
return map[string]any{
keyOrgName: fixtureOrgName,
diff --git a/server/internal/handlers/emailpreview/locale_account_test.go b/server/internal/handlers/emailpreview/locale_account_test.go
index 5480e170c..22ca56c5a 100644
--- a/server/internal/handlers/emailpreview/locale_account_test.go
+++ b/server/internal/handlers/emailpreview/locale_account_test.go
@@ -20,7 +20,7 @@ import (
//nolint:gochecknoglobals // read-only test table.
var accountTemplates = []string{
"registration.html", "welcome.html", "password-reset.html", "password-reset-sso.html",
- "password-changed.html", "email-changed.html", "invitation.html",
+ "password-changed.html", "email-changed.html", "device-token-created.html", "invitation.html",
"membership-request-new.html", "membership-request-decision.html",
}
@@ -130,6 +130,24 @@ func TestAccountMail_EveryLocale(t *testing.T) {
i18n.LangGerman: {"Ihr SolidPing-Passwort wurde geändert", "am 5. Juli, 10:00 UTC geändert wurde."},
i18n.LangSpanish: {"Se ha cambiado tu contraseña de SolidPing", "se cambió el 5 jul, 10:00 UTC."},
},
+ "device-token-created.html": {
+ i18n.LangEnglish: {
+ "A device was signed in to your SolidPing account",
+ "The request was opened from IP address 203.0.113.7.",
+ },
+ i18n.LangFrench: {
+ "Un appareil s'est connecté à votre compte SolidPing",
+ "La demande a été ouverte depuis l'adresse IP 203.0.113.7.",
+ },
+ i18n.LangGerman: {
+ "Ein Gerät wurde bei Ihrem SolidPing-Konto angemeldet",
+ "Die Anfrage wurde von der IP-Adresse 203.0.113.7 gestellt.",
+ },
+ i18n.LangSpanish: {
+ "Se ha conectado un dispositivo a tu cuenta de SolidPing",
+ "La solicitud se abrió desde la dirección IP 203.0.113.7.",
+ },
+ },
"email-changed.html": {
i18n.LangEnglish: {
"Your SolidPing email address was changed",
diff --git a/server/internal/i18n/locales/de/account.json b/server/internal/i18n/locales/de/account.json
index f34d7e0c9..6ffd02cd4 100644
--- a/server/internal/i18n/locales/de/account.json
+++ b/server/internal/i18n/locales/de/account.json
@@ -66,6 +66,15 @@
"body": "Die E-Mail-Adresse Ihres SolidPing-Kontos wurde am {date} von {oldEmail} in {newEmail} geändert.",
"newAddress": "Sie melden sich jetzt mit {newEmail} an. Diese Adresse erhält keine E-Mails mehr zu Ihrem Konto."
},
+ "deviceTokenCreated": {
+ "subject": "Ein Gerät wurde bei Ihrem SolidPing-Konto angemeldet",
+ "preheader": "{client} hat jetzt ein API-Token für {org}.",
+ "title": "Ein neues Geräte-Token wurde erstellt",
+ "body": "Sie haben am {date} die Anmeldung eines Geräts bestätigt. Die Anwendung, die sich „{client}“ nennt, hat ein API-Token für die Organisation {org} erhalten, gültig für 90 Tage.",
+ "requester": "Die Anfrage wurde von der IP-Adresse {ip} gestellt.",
+ "revoke": "Wenn Sie diese Anmeldung nicht selbst gestartet haben, widerrufen Sie das Token jetzt auf der Token-Seite Ihres Kontos:",
+ "cta": "Meine Tokens ansehen"
+ },
"invitation": {
"subject": "Einladung zu {org} bei SolidPing",
"preheader": {
diff --git a/server/internal/i18n/locales/en/account.json b/server/internal/i18n/locales/en/account.json
index 4f29665f9..40f10a613 100644
--- a/server/internal/i18n/locales/en/account.json
+++ b/server/internal/i18n/locales/en/account.json
@@ -66,6 +66,15 @@
"body": "The email address of your SolidPing account was changed from {oldEmail} to {newEmail} on {date}.",
"newAddress": "You now sign in with {newEmail}. This address will no longer receive account emails."
},
+ "deviceTokenCreated": {
+ "subject": "A device was signed in to your SolidPing account",
+ "preheader": "{client} now has an API token for {org}.",
+ "title": "A new device token was created",
+ "body": "You approved a device sign-in on {date}. The application calling itself \"{client}\" received an API token for the organization {org}, valid for 90 days.",
+ "requester": "The request was opened from IP address {ip}.",
+ "revoke": "If you did not start this sign-in yourself, revoke the token now from your account tokens page:",
+ "cta": "Review my tokens"
+ },
"invitation": {
"subject": "You're invited to join {org} on SolidPing",
"preheader": {
diff --git a/server/internal/i18n/locales/es/account.json b/server/internal/i18n/locales/es/account.json
index fbb52c8e4..939ffb507 100644
--- a/server/internal/i18n/locales/es/account.json
+++ b/server/internal/i18n/locales/es/account.json
@@ -66,6 +66,15 @@
"body": "La dirección de correo de tu cuenta de SolidPing se cambió de {oldEmail} a {newEmail} el {date}.",
"newAddress": "Ahora inicias sesión con {newEmail}. Esta dirección ya no recibirá los correos de tu cuenta."
},
+ "deviceTokenCreated": {
+ "subject": "Se ha conectado un dispositivo a tu cuenta de SolidPing",
+ "preheader": "{client} tiene ahora un token de API para {org}.",
+ "title": "Se ha creado un nuevo token de dispositivo",
+ "body": "Aprobaste el inicio de sesión de un dispositivo el {date}. La aplicación que se presenta como «{client}» recibió un token de API para la organización {org}, válido durante 90 días.",
+ "requester": "La solicitud se abrió desde la dirección IP {ip}.",
+ "revoke": "Si no iniciaste tú este inicio de sesión, revoca el token ahora desde la página de tokens de tu cuenta:",
+ "cta": "Ver mis tokens"
+ },
"invitation": {
"subject": "Te han invitado a unirte a {org} en SolidPing",
"preheader": {
diff --git a/server/internal/i18n/locales/fr/account.json b/server/internal/i18n/locales/fr/account.json
index e9c41790c..44fee6c06 100644
--- a/server/internal/i18n/locales/fr/account.json
+++ b/server/internal/i18n/locales/fr/account.json
@@ -66,6 +66,15 @@
"body": "L'adresse e-mail de votre compte SolidPing a été remplacée par {newEmail} le {date}. L'ancienne adresse était {oldEmail}.",
"newAddress": "Vous vous connectez désormais avec {newEmail}. Cette adresse ne recevra plus les e-mails liés à votre compte."
},
+ "deviceTokenCreated": {
+ "subject": "Un appareil s'est connecté à votre compte SolidPing",
+ "preheader": "{client} dispose désormais d'un jeton d'API pour {org}.",
+ "title": "Un nouveau jeton d'appareil a été créé",
+ "body": "Vous avez approuvé la connexion d'un appareil le {date}. L'application qui se présente comme « {client} » a reçu un jeton d'API pour l'organisation {org}, valable 90 jours.",
+ "requester": "La demande a été ouverte depuis l'adresse IP {ip}.",
+ "revoke": "Si vous n'avez pas lancé cette connexion vous-même, révoquez ce jeton dès maintenant depuis la page des jetons de votre compte :",
+ "cta": "Voir mes jetons"
+ },
"invitation": {
"subject": "Invitation à rejoindre {org} sur SolidPing",
"preheader": {
diff --git a/server/internal/middleware/ratelimit.go b/server/internal/middleware/ratelimit.go
index 520ff2cb4..2f329e20a 100644
--- a/server/internal/middleware/ratelimit.go
+++ b/server/internal/middleware/ratelimit.go
@@ -6,7 +6,6 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
- "net"
"net/http"
"strconv"
"strings"
@@ -208,51 +207,15 @@ func isExcluded(path string) bool {
return false
}
-// extractIPFromXFF parses X-Forwarded-For and returns the real client IP.
-// Each proxy hop appends the peer address it accepted the connection from,
-// so with N trusted hops the client is the Nth entry from the right —
-// parts[len(parts)-trustedProxies] (0-indexed). When the header carries
-// fewer entries than trusted hops (e.g. a single-entry header behind one
-// proxy that overwrote a client-supplied value), the left-most entry is the
-// best available answer, so the index is clamped to 0.
+// extractIPFromXFF and extractIP delegate to the shared trusted-proxy
+// resolution in handlers/base, so the rate limiter and the handlers that
+// record a client IP (device-flow requester, spec 2026-10-27-01) agree.
func extractIPFromXFF(xff string, trustedProxies int) string {
- if trustedProxies <= 0 {
- return ""
- }
- parts := strings.Split(xff, ",")
- idx := len(parts) - trustedProxies
- if idx < 0 {
- idx = 0
- }
- return strings.TrimSpace(parts[idx])
+ return base.ExtractIPFromXFF(xff, trustedProxies)
}
-// extractIP returns the client IP, respecting the configured TrustedProxies
-// count.
-//
-// Trust model: setting TrustedProxies > 0 asserts that the deployment sits
-// behind that many proxy hops which *strip or overwrite* client-supplied
-// X-Forwarded-For / X-Real-IP headers (traefik and most managed ingresses
-// do; verify before enabling behind a bare ALB or default nginx). X-Real-IP
-// is only consulted when X-Forwarded-For is absent entirely — a present but
-// unparsable XFF falls through to RemoteAddr, never to the equally
-// client-controllable X-Real-IP.
func extractIP(req *http.Request, trustedProxies int) string {
- if trustedProxies > 0 {
- if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
- if ip := extractIPFromXFF(xff, trustedProxies); ip != "" {
- return ip
- }
- } else if xri := req.Header.Get("X-Real-IP"); xri != "" {
- return strings.TrimSpace(xri)
- }
- }
-
- host, _, err := net.SplitHostPort(req.RemoteAddr)
- if err != nil {
- return req.RemoteAddr
- }
- return host
+ return base.ExtractTrustedClientIP(req, trustedProxies)
}
// Bucket-kind labels for the caller's rate/concurrency bucket, reported by
diff --git a/server/pkg/client/client_generated.go b/server/pkg/client/client_generated.go
index 4d1c58e3a..e72aefe3f 100644
--- a/server/pkg/client/client_generated.go
+++ b/server/pkg/client/client_generated.go
@@ -5853,10 +5853,21 @@ type DeviceAuthorizationResponse struct {
// DeviceConsentInfo defines model for DeviceConsentInfo.
type DeviceConsentInfo struct {
- ClientName string `json:"clientName"`
- ExpiresAt time.Time `json:"expiresAt"`
- Status DeviceConsentInfoStatus `json:"status"`
- UserCode string `json:"userCode"`
+ // ApproverIp IP of the caller of this endpoint, for a "different network" hint.
+ ApproverIp *string `json:"approverIp,omitempty"`
+ ClientName string `json:"clientName"`
+ ExpiresAt time.Time `json:"expiresAt"`
+
+ // RequestedAt When the device-authorization request was opened.
+ RequestedAt time.Time `json:"requestedAt"`
+
+ // RequesterIp IP that opened the request (trusted-proxy resolution). Absent for requests opened before it was recorded.
+ RequesterIp *string `json:"requesterIp,omitempty"`
+
+ // RequesterUserAgent Short summary of the requesting client's User-Agent. Absent when none was sent.
+ RequesterUserAgent *string `json:"requesterUserAgent,omitempty"`
+ Status DeviceConsentInfoStatus `json:"status"`
+ UserCode string `json:"userCode"`
}
// DeviceConsentInfoStatus defines model for DeviceConsentInfo.Status.
From fc313b0e8bdf9c7504c3f7427ee038b5572d28a8 Mon Sep 17 00:00:00 2001
From: Florent Clairambault
Date: Tue, 27 Oct 2026 10:18:37 +0100
Subject: [PATCH 05/13] feat(dash0): phishing warning and requester details on
device consent
The authorize card now shows when, from which IP and with which client the
request was opened, the approver's own IP with a different-network hint, a
destructive warning above Approve, labels the client name as self-reported,
and requires an "I started this sign-in myself" tick when the code arrived
pre-filled from the URL (spec 2026-10-27-01).
Co-Authored-By: Claude Opus 5.5
---
web/dash0/e2e/device-authorization.spec.ts | 50 ++++++++++++
web/dash0/src/api/hooks.ts | 8 ++
web/dash0/src/locales/de/account.json | 14 +++-
web/dash0/src/locales/en/account.json | 14 +++-
web/dash0/src/locales/es/account.json | 14 +++-
web/dash0/src/locales/fr/account.json | 14 +++-
.../src/routes/orgs/$org/account.device.tsx | 76 ++++++++++++++++++-
7 files changed, 181 insertions(+), 9 deletions(-)
diff --git a/web/dash0/e2e/device-authorization.spec.ts b/web/dash0/e2e/device-authorization.spec.ts
index 0b1536093..9b81653f5 100644
--- a/web/dash0/e2e/device-authorization.spec.ts
+++ b/web/dash0/e2e/device-authorization.spec.ts
@@ -96,6 +96,18 @@ test.describe("Device authorization consent", () => {
await expect(page.getByTestId("device-consent-card")).toBeVisible();
await expect(page.getByTestId("device-client-name")).toHaveText(clientName);
+ // Phishing guard (spec 2026-10-27-01): the card warns, shows who opened
+ // the request, and a code that arrived pre-filled needs an explicit tick.
+ await expect(page.getByTestId("device-phishing-warning")).toBeVisible();
+ await expect(page.getByTestId("device-request-details")).toBeVisible();
+ await expect(page.getByTestId("device-requester-ip")).toHaveText(
+ /^[0-9a-fA-F.:]+$/,
+ );
+ await expect(page.getByTestId("device-requested-at")).not.toBeEmpty();
+ await expect(page.getByTestId("device-approve")).toBeDisabled();
+ await page.getByTestId("device-confirm-started").click();
+ await expect(page.getByTestId("device-approve")).toBeEnabled();
+
await page.getByTestId("device-approve").click();
await expect(page.getByTestId("device-approved")).toBeVisible();
@@ -161,6 +173,44 @@ test.describe("Device authorization consent", () => {
expect(names).not.toContain(clientName);
});
+ test("a code typed on the page approves without the pre-filled tick", async ({
+ authenticatedPage,
+ }) => {
+ const page = authenticatedPage;
+ const clientName = `sp CLI typed ${Date.now()}`;
+ const authz = await startDeviceAuthorization(page, clientName);
+
+ await page.goto("orgs/test/account/device");
+ await page.getByTestId("device-user-code").fill(authz.user_code);
+ await page.getByTestId("device-lookup").click();
+
+ await expect(page.getByTestId("device-consent-card")).toBeVisible();
+ await expect(page.getByTestId("device-phishing-warning")).toBeVisible();
+ await expect(page.getByTestId("device-confirm-started")).toHaveCount(0);
+ await expect(page.getByTestId("device-approve")).toBeEnabled();
+
+ await page.getByTestId("device-approve").click();
+ await expect(page.getByTestId("device-approved")).toBeVisible();
+
+ const granted = await collectDeviceToken(page, authz.device_code);
+ expect(granted.status).toBe(200);
+
+ // Clean up the minted PAT.
+ const token = await getAuthToken(page);
+ const list = await page.request.get(
+ `${API_BASE}/api/v1/orgs/test/tokens?type=pat`,
+ { headers: { Authorization: `Bearer ${token}` } },
+ );
+ const minted = ((await list.json()).data ?? []).find(
+ (entry: { name?: string; uid: string }) => entry.name === clientName,
+ );
+ expect(minted, "the approved PAT must be listed").toBeTruthy();
+ await page.request.delete(
+ `${API_BASE}/api/v1/auth/tokens/${minted.uid}`,
+ { headers: { Authorization: `Bearer ${token}` } },
+ );
+ });
+
test("an unknown code is reported, not silently swallowed", async ({
authenticatedPage,
}) => {
diff --git a/web/dash0/src/api/hooks.ts b/web/dash0/src/api/hooks.ts
index 503707a02..d49013d22 100644
--- a/web/dash0/src/api/hooks.ts
+++ b/web/dash0/src/api/hooks.ts
@@ -3031,6 +3031,14 @@ export interface DeviceConsentInfo {
userCode: string;
status: "pending" | "approved" | "denied";
expiresAt: string;
+ /** When the request was opened (spec 2026-10-27-01). */
+ requestedAt: string;
+ /** IP that opened the request; absent for older requests. */
+ requesterIp?: string;
+ /** Short summary of the requesting client's User-Agent. */
+ requesterUserAgent?: string;
+ /** IP of the approving browser, for the "different network" hint. */
+ approverIp?: string;
}
export interface DeviceConsentRequest {
diff --git a/web/dash0/src/locales/de/account.json b/web/dash0/src/locales/de/account.json
index 8312ab3c0..4ac62b622 100644
--- a/web/dash0/src/locales/de/account.json
+++ b/web/dash0/src/locales/de/account.json
@@ -302,7 +302,7 @@
"alreadyHandledTitle": "Diese Anfrage wurde bereits bearbeitet",
"alreadyHandledDescription": "Hier gibt es nichts mehr zu tun — starten Sie eine neue Anmeldung über Ihr Terminal, falls Sie weiterhin Zugriff benötigen.",
"authorizeTitle": "Dieses Gerät autorisieren",
- "authorizeDescription": "{{clientName}} möchte sich als Sie anmelden. Durch die Genehmigung wird ein persönliches Zugriffstoken erstellt, das 90 Tage gültig ist und jederzeit unter Konto → Tokens widerrufen werden kann.",
+ "authorizeDescription": "Eine Anwendung, die sich {{clientName}} nennt, möchte sich als Sie anmelden. Durch die Genehmigung wird ein persönliches Zugriffstoken erstellt, das 90 Tage gültig ist und jederzeit unter Konto → Tokens widerrufen werden kann.",
"organization": "Organisation",
"organizationHint": "Das Token kann nur auf diese Organisation zugreifen.",
"expires": "Läuft ab {{time}}",
@@ -313,7 +313,17 @@
"deniedTitle": "Geräteanmeldung abgelehnt",
"deniedDescription": "Es wurde kein Token erstellt. Die Kommandozeile meldet, dass die Anfrage abgelehnt wurde.",
"approvedToast": "Gerät genehmigt — kehren Sie zu Ihrem Terminal zurück",
- "decisionFailed": "Ihre Entscheidung konnte nicht gespeichert werden"
+ "decisionFailed": "Ihre Entscheidung konnte nicht gespeichert werden",
+ "phishingTitle": "Genehmigen Sie nur eine selbst gestartete Anmeldung",
+ "phishingWarning": "Genehmigen Sie nur, wenn Sie gerade selbst eine SolidPing-Anmeldung gestartet haben, auf einem Gerät vor Ihnen. Genehmigen Sie niemals einen Code, den Ihnen jemand geschickt hat.",
+ "requestDetails": "Details der Anfrage",
+ "requestedAt": "Angefragt",
+ "requesterIp": "Von IP-Adresse",
+ "requesterClient": "Client-Software",
+ "approverIp": "Ihre IP-Adresse",
+ "unknownValue": "Unbekannt",
+ "differentNetwork": "Diese Anfrage kam aus einem anderen Netzwerk als dem, das Sie gerade nutzen.",
+ "confirmStarted": "Ich habe diese Anmeldung selbst gestartet"
},
"email": {
"title": "E-Mail",
diff --git a/web/dash0/src/locales/en/account.json b/web/dash0/src/locales/en/account.json
index eca5b5ca0..bffd3bb49 100644
--- a/web/dash0/src/locales/en/account.json
+++ b/web/dash0/src/locales/en/account.json
@@ -302,7 +302,7 @@
"alreadyHandledTitle": "This request was already handled",
"alreadyHandledDescription": "Nothing left to do here — start a new login from your terminal if you still need access.",
"authorizeTitle": "Authorize this device",
- "authorizeDescription": "{{clientName}} is asking to sign in as you. Approving creates a personal access token for it, valid for 90 days, which you can revoke any time from Account → Tokens.",
+ "authorizeDescription": "An application that calls itself {{clientName}} is asking to sign in as you. Approving creates a personal access token for it, valid for 90 days, which you can revoke any time from Account → Tokens.",
"organization": "Organization",
"organizationHint": "The token will only be able to reach this organization.",
"expires": "Expires {{time}}",
@@ -313,7 +313,17 @@
"deniedTitle": "Device login denied",
"deniedDescription": "No token was created. The command line will report that the request was denied.",
"approvedToast": "Device approved — return to your terminal",
- "decisionFailed": "Could not record your decision"
+ "decisionFailed": "Could not record your decision",
+ "phishingTitle": "Only approve a sign-in you started",
+ "phishingWarning": "Only approve if you just ran a SolidPing sign-in yourself, on a device in front of you. Never approve a code someone sent you.",
+ "requestDetails": "Request details",
+ "requestedAt": "Requested",
+ "requesterIp": "From IP address",
+ "requesterClient": "Client software",
+ "approverIp": "Your IP address",
+ "unknownValue": "Unknown",
+ "differentNetwork": "This request came from a different network than the one you are using now.",
+ "confirmStarted": "I started this sign-in myself"
},
"email": {
"title": "Email",
diff --git a/web/dash0/src/locales/es/account.json b/web/dash0/src/locales/es/account.json
index 69ea066a0..4dab9ecfe 100644
--- a/web/dash0/src/locales/es/account.json
+++ b/web/dash0/src/locales/es/account.json
@@ -302,7 +302,7 @@
"alreadyHandledTitle": "Esta solicitud ya se ha gestionado",
"alreadyHandledDescription": "Aquí no queda nada por hacer — inicia un nuevo inicio de sesión desde tu terminal si todavía necesitas acceso.",
"authorizeTitle": "Autorizar este dispositivo",
- "authorizeDescription": "{{clientName}} solicita iniciar sesión como tú. Aprobarlo crea un token de acceso personal válido durante 90 días, que puedes revocar en cualquier momento desde Cuenta → Tokens.",
+ "authorizeDescription": "Una aplicación que se presenta como {{clientName}} solicita iniciar sesión como tú. Aprobarlo crea un token de acceso personal válido durante 90 días, que puedes revocar en cualquier momento desde Cuenta → Tokens.",
"organization": "Organización",
"organizationHint": "El token solo podrá acceder a esta organización.",
"expires": "Caduca {{time}}",
@@ -313,7 +313,17 @@
"deniedTitle": "Inicio de sesión del dispositivo denegado",
"deniedDescription": "No se creó ningún token. La línea de comandos informará que la solicitud fue denegada.",
"approvedToast": "Dispositivo aprobado — vuelve a tu terminal",
- "decisionFailed": "No se pudo registrar tu decisión"
+ "decisionFailed": "No se pudo registrar tu decisión",
+ "phishingTitle": "Aprueba solo un inicio de sesión que hayas iniciado tú",
+ "phishingWarning": "Aprueba solo si acabas de iniciar tú mismo un inicio de sesión de SolidPing, en un dispositivo que tienes delante. Nunca apruebes un código que alguien te haya enviado.",
+ "requestDetails": "Detalles de la solicitud",
+ "requestedAt": "Solicitada",
+ "requesterIp": "Desde la dirección IP",
+ "requesterClient": "Software cliente",
+ "approverIp": "Tu dirección IP",
+ "unknownValue": "Desconocido",
+ "differentNetwork": "Esta solicitud llegó desde una red distinta de la que estás usando ahora.",
+ "confirmStarted": "He iniciado yo mismo este inicio de sesión"
},
"email": {
"title": "Correo electrónico",
diff --git a/web/dash0/src/locales/fr/account.json b/web/dash0/src/locales/fr/account.json
index 53a93bb4c..71c0051cd 100644
--- a/web/dash0/src/locales/fr/account.json
+++ b/web/dash0/src/locales/fr/account.json
@@ -302,7 +302,7 @@
"alreadyHandledTitle": "Cette demande a déjà été traitée",
"alreadyHandledDescription": "Il n'y a plus rien à faire ici — lancez une nouvelle connexion depuis votre terminal si vous avez encore besoin d'accès.",
"authorizeTitle": "Autoriser cet appareil",
- "authorizeDescription": "{{clientName}} demande à se connecter en votre nom. Approuver crée un jeton d'accès personnel valable 90 jours, que vous pouvez révoquer à tout moment depuis Compte → Jetons.",
+ "authorizeDescription": "Une application qui se présente comme {{clientName}} demande à se connecter en votre nom. Approuver crée un jeton d'accès personnel valable 90 jours, que vous pouvez révoquer à tout moment depuis Compte → Jetons.",
"organization": "Organisation",
"organizationHint": "Le jeton ne pourra accéder qu'à cette organisation.",
"expires": "Expire {{time}}",
@@ -313,7 +313,17 @@
"deniedTitle": "Connexion de l'appareil refusée",
"deniedDescription": "Aucun jeton n'a été créé. La ligne de commande signalera que la demande a été refusée.",
"approvedToast": "Appareil approuvé — retournez à votre terminal",
- "decisionFailed": "Impossible d'enregistrer votre décision"
+ "decisionFailed": "Impossible d'enregistrer votre décision",
+ "phishingTitle": "N'approuvez qu'une connexion lancée par vous",
+ "phishingWarning": "N'approuvez que si vous venez de lancer vous-même une connexion SolidPing, sur un appareil devant vous. N'approuvez jamais un code que quelqu'un vous a envoyé.",
+ "requestDetails": "Détails de la demande",
+ "requestedAt": "Demandée",
+ "requesterIp": "Depuis l'adresse IP",
+ "requesterClient": "Logiciel client",
+ "approverIp": "Votre adresse IP",
+ "unknownValue": "Inconnu",
+ "differentNetwork": "Cette demande vient d'un autre réseau que celui que vous utilisez actuellement.",
+ "confirmStarted": "J'ai lancé cette connexion moi-même"
},
"email": {
"title": "Adresse e-mail",
diff --git a/web/dash0/src/routes/orgs/$org/account.device.tsx b/web/dash0/src/routes/orgs/$org/account.device.tsx
index ad7025899..24754e374 100644
--- a/web/dash0/src/routes/orgs/$org/account.device.tsx
+++ b/web/dash0/src/routes/orgs/$org/account.device.tsx
@@ -5,6 +5,7 @@ import {
AlertCircle,
CheckCircle2,
MonitorSmartphone,
+ ShieldAlert,
Terminal,
} from "lucide-react";
import { toast } from "sonner";
@@ -17,6 +18,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { PageHeader } from "@/components/shared/page-header";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
import {
Card,
CardContent,
@@ -49,6 +51,13 @@ import { Skeleton } from "@/components/ui/skeleton";
* here — not implicitly to the current session's org — so a member of several
* orgs picks which one the CLI ends up talking to.
*
+ * Device-code phishing guard (RFC 8628 §5.4, spec 2026-10-27-01): anyone can
+ * open a request and send a member the pre-filled link. The authorize card
+ * therefore shows who opened the request (when, from which IP, with which
+ * client), warns against approving a code someone sent, labels the client name
+ * as self-reported, and asks for an "I started this myself" tick when the code
+ * arrived pre-filled in the URL rather than typed here.
+ *
* The whole page is single-column and touch-sized: approving from a phone,
* while the CLI runs on a headless box, is the entire point of the flow.
*/
@@ -102,6 +111,13 @@ function DeviceConsentPage() {
// burn the consent endpoint's (deliberately tight) rate limit.
const submitted = isCompleteUserCode(urlCode) ? urlCode : "";
+ // The code the user typed and submitted on this page. A code that is in the
+ // URL but was not typed here arrived pre-filled from a link, which is exactly
+ // how a phishing code arrives, so approving it needs an explicit tick.
+ const [typedCode, setTypedCode] = useState(null);
+ const [confirmedStarted, setConfirmedStarted] = useState(false);
+ const needsConfirmation = submitted !== "" && submitted !== typedCode;
+
const [selectedOrg, setSelectedOrg] = useState(org);
const [decision, setDecision] = useState<"approved" | "denied" | null>(null);
@@ -114,6 +130,7 @@ function DeviceConsentPage() {
const isSingleOrg = orgOptions.length === 1;
function lookUp() {
+ setTypedCode(formatUserCode(code));
void navigate({
to: ".",
search: { user_code: formatUserCode(code) },
@@ -275,6 +292,61 @@ function DeviceConsentPage() {
})}