From 0423723d43a35f2d8e81dac9646d025d313b9011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Sun, 16 Aug 2026 14:29:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20GrantInfo=20=E2=80=94=20expose=20?= =?UTF-8?q?pending=20grant=20scopes=20+=20remaining=20TTL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approve UI needs to show what a user code asks for and how long it is valid; GrantInfo returns (scopes, secondsLeft, ok) without consuming the grant (consumption stays one-shot in Token). --- auth/device.go | 21 +++++++++++++++++++++ auth/device_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/auth/device.go b/auth/device.go index 5dd3acd..54cfb7b 100644 --- a/auth/device.go +++ b/auth/device.go @@ -144,6 +144,27 @@ func (f *DeviceFlow) Approve(userCode string, approver *Principal) error { return nil } +// GrantInfo returns what a pending grant's client asked for (scopes) and +// how long the code is still valid (seconds). The approve UI shows both so +// the admin consents to exactly what the companion app will get and knows +// how long they have. Returns (nil, 0, false) for unknown/expired codes. +func (f *DeviceFlow) GrantInfo(userCode string) (scopes []string, expiresIn int, ok bool) { + if f == nil { + return nil, 0, false + } + f.mu.Lock() + defer f.mu.Unlock() + dc, ok := f.byCode[userCode] + if !ok { + return nil, 0, false + } + s, ok := f.pending[dc] + if !ok || f.now().After(s.expiresAt) { + return nil, 0, false + } + return append([]string(nil), s.requested...), int(s.expiresAt.Sub(f.now()) / time.Second), true +} + // RequestedScopes returns the scopes a pending grant's client asked for. // The approve UI shows them so the admin consents to exactly what the // companion app will get. Returns (nil, false) for unknown/expired codes. diff --git a/auth/device_test.go b/auth/device_test.go index 7478f1d..050c2f7 100644 --- a/auth/device_test.go +++ b/auth/device_test.go @@ -195,6 +195,32 @@ func TestDeviceFlowRequestedScopes(t *testing.T) { } } +func TestDeviceFlowGrantInfo(t *testing.T) { + now := time.Now() + f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{TTL: 10 * time.Minute}) + f.now = func() time.Time { return now } + _, uc := mustStartScoped(t, f, "mcp", "read") + scopes, expiresIn, ok := f.GrantInfo(uc) + if !ok { + t.Fatal("pending grant should report info") + } + if len(scopes) != 2 || scopes[0] != "mcp" || scopes[1] != "read" { + t.Fatalf("scopes = %v, want [mcp read]", scopes) + } + if expiresIn <= 0 || expiresIn > 600 { + t.Fatalf("expiresIn = %d, want 0 < x <= 600", expiresIn) + } + // Unknown code. + if _, _, ok := f.GrantInfo("NOPE-XXXX"); ok { + t.Fatal("unknown code should report not found") + } + // Expired code reports not found (clock advanced past TTL). + f.now = func() time.Time { return now.Add(11 * time.Minute) } + if _, _, ok := f.GrantInfo(uc); ok { + t.Fatal("expired code should report not found") + } +} + func TestDeviceFlowFullLegacy(t *testing.T) { f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{BaseURL: "https://admin.example.com"}) dc, uc, uri, complete, exp, interval := mustStart(t, f)