Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion server/internal/app/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10862,7 +10862,7 @@ components:

DeviceConsentInfo:
type: object
required: [clientName, userCode, status, expiresAt]
required: [clientName, userCode, status, expiresAt, requestedAt]
properties:
clientName:
type: string
Expand All @@ -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
Expand Down
31 changes: 18 additions & 13 deletions server/internal/db/models/device_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
24 changes: 24 additions & 0 deletions server/internal/db/postgres/migrations/031_v0_43_0.tx.up.sql
Original file line number Diff line number Diff line change
@@ -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.';
5 changes: 5 additions & 0 deletions server/internal/db/sqlite/migrations/031_v0_43_0.tx.down.sql
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions server/internal/db/sqlite/migrations/031_v0_43_0.tx.up.sql
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions server/internal/email/supportreply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -141,6 +142,7 @@ var supportReplyableTemplates = map[string]bool{
TemplateInvitation: false,
TemplatePasswordChanged: false,
TemplateEmailChanged: false,
TemplateDeviceTokenCreated: false,
TemplatePasswordReset: false,
TemplatePasswordResetSSO: false,
TemplateRegistration: false,
Expand Down
21 changes: 21 additions & 0 deletions server/internal/email/templates/device-token-created.html
Original file line number Diff line number Diff line change
@@ -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"}}
<h1>{{t "account.deviceTokenCreated.title"}}</h1>
<p>{{t "account.deviceTokenCreated.body" "client" .ClientName "org" .OrgName "date" (date .CreatedAt)}}</p>
{{if .RequesterIP}}<p>{{t "account.deviceTokenCreated.requester" "ip" .RequesterIP}}</p>{{end}}
<p>{{t "account.deviceTokenCreated.revoke"}}</p>
<div class="cta-wrap">{{template "button" dict "URL" .TokensURL "Label" (t "account.deviceTokenCreated.cta") "Variant" "primary"}}</div>
<p class="meta">{{t "account.securityNotice.warning"}}</p>
{{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}}
15 changes: 14 additions & 1 deletion server/internal/handlers/auth/device_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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".
Expand Down
58 changes: 58 additions & 0 deletions server/internal/handlers/auth/device_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
})
}
}
Loading
Loading