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).
333 lines
9.8 KiB
Go
333 lines
9.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLocalJWTMintVerify(t *testing.T) {
|
|
l := NewLocalJWT("test-secret")
|
|
tok, err := l.Mint("device-1", []string{"mcp"}, time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(tok, "eyJ") {
|
|
t.Fatalf("token should start with base64 header: %q", tok[:8])
|
|
}
|
|
p, err := l.Verify(tok)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p.Subject != "device-1" || p.Kind != KindDevice || !p.HasScope("mcp") {
|
|
t.Fatalf("principal = %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestLocalJWTTampered(t *testing.T) {
|
|
l := NewLocalJWT("test-secret")
|
|
tok, _ := l.Mint("d", nil, time.Hour)
|
|
// Flip one char in the payload segment.
|
|
parts := strings.Split(tok, ".")
|
|
payload := []byte(parts[1])
|
|
payload[0] ^= 1
|
|
tampered := parts[0] + "." + string(payload) + "." + parts[2]
|
|
if _, err := l.Verify(tampered); err == nil {
|
|
t.Fatal("tampered token verified")
|
|
}
|
|
// Wrong secret.
|
|
l2 := NewLocalJWT("other-secret")
|
|
if _, err := l2.Verify(tok); err == nil {
|
|
t.Fatal("token verified with wrong secret")
|
|
}
|
|
}
|
|
|
|
func TestLocalJWTExpiry(t *testing.T) {
|
|
l := NewLocalJWT("s")
|
|
// Pin the clock.
|
|
now := time.Unix(1_700_000_000, 0)
|
|
l.now = func() time.Time { return now }
|
|
tok, _ := l.Mint("d", nil, time.Minute)
|
|
l.now = func() time.Time { return now.Add(2 * time.Minute) }
|
|
if _, err := l.Verify(tok); !errors.Is(err, ErrTokenExpired) {
|
|
t.Fatalf("err = %v, want ErrTokenExpired", err)
|
|
}
|
|
}
|
|
|
|
func TestLocalJWTDisabled(t *testing.T) {
|
|
l := NewLocalJWT("")
|
|
if l.Enabled() {
|
|
t.Fatal("empty secret should be disabled")
|
|
}
|
|
if _, err := l.Mint("d", nil, time.Minute); err == nil {
|
|
t.Fatal("mint with empty secret should fail")
|
|
}
|
|
if _, err := l.Verify("eyJ.eyJ.abc"); !errors.Is(err, ErrNoCredentials) {
|
|
t.Fatalf("err = %v, want ErrNoCredentials", err)
|
|
}
|
|
}
|
|
|
|
func TestLocalJWTAuthenticate(t *testing.T) {
|
|
l := NewLocalJWT("s")
|
|
tok, _ := l.Mint("d", nil, time.Minute)
|
|
req, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
p, err := l.Authenticate(req)
|
|
if err != nil || p.Subject != "d" {
|
|
t.Fatalf("authenticate: %v %+v", err, p)
|
|
}
|
|
// No credentials.
|
|
req2, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
if _, err := l.Authenticate(req2); !errors.Is(err, ErrNoCredentials) {
|
|
t.Fatalf("err = %v, want ErrNoCredentials", err)
|
|
}
|
|
}
|
|
|
|
func TestDeviceFlowFull(t *testing.T) {
|
|
f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{BaseURL: "https://admin.example.com"})
|
|
dc, uc := mustStartScoped(t, f)
|
|
// No requested scopes: the approver's scopes become the token's.
|
|
if err := f.Approve(uc, &Principal{Subject: "user-1", Scopes: []string{"mcp", "write"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, err := f.Token(dc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, err := f.jwt.Verify(tok)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p.Subject != "user-1" || !p.HasScope("mcp") || !p.HasScope("write") {
|
|
t.Fatalf("token principal = %+v", p)
|
|
}
|
|
// One-shot: second poll fails.
|
|
if _, err := f.Token(dc); !errors.Is(err, ErrDeviceUnknown) {
|
|
t.Fatalf("err = %v, want ErrDeviceUnknown after consumption", err)
|
|
}
|
|
}
|
|
|
|
func TestDeviceFlowRequestedScopes(t *testing.T) {
|
|
f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{})
|
|
dc, uc := startScoped(t, f, "mcp", "write")
|
|
// Requested scopes show up for the approve page.
|
|
req, ok := f.RequestedScopes(uc)
|
|
if !ok || len(req) != 2 {
|
|
t.Fatalf("requested = %v %v, want [mcp write]", req, ok)
|
|
}
|
|
// Approver without scopes (browser admin session) passes requested through.
|
|
if err := f.Approve(uc, &Principal{Subject: "admin"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, err := f.Token(dc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, _ := f.jwt.Verify(tok)
|
|
if !p.HasScope("mcp") || !p.HasScope("write") {
|
|
t.Fatalf("requested scopes should pass through: %+v", p)
|
|
}
|
|
// No requested scopes: the approver's scopes become the token's.
|
|
dc0, uc0 := mustStartScoped(t, f)
|
|
if err := f.Approve(uc0, &Principal{Subject: "admin", Scopes: []string{"read"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok0, err := f.Token(dc0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p0, _ := f.jwt.Verify(tok0)
|
|
if !p0.HasScope("read") {
|
|
t.Fatalf("no requested scopes should use approver scopes: %+v", p0)
|
|
}
|
|
// Requested ∩ approver: request [mcp, read], approver [write] → [] (denied
|
|
// everything the approver can't grant).
|
|
dc2, uc2 := startScoped(t, f, "mcp", "read")
|
|
if err := f.Approve(uc2, &Principal{Subject: "admin", Scopes: []string{"write"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok2, err := f.Token(dc2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p2, _ := f.jwt.Verify(tok2)
|
|
if len(p2.Scopes) != 0 {
|
|
t.Fatalf("no overlap should grant nothing: %+v", p2)
|
|
}
|
|
// Approver with admin grants any requested scope.
|
|
dcAdm, ucAdm := startScoped(t, f, "mcp", "read", "write")
|
|
if err := f.Approve(ucAdm, &Principal{Subject: "boss", Scopes: []string{"admin"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tokAdm, err := f.Token(dcAdm)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pAdm, _ := f.jwt.Verify(tokAdm)
|
|
for _, s := range []string{"mcp", "read", "write"} {
|
|
if !pAdm.HasScope(s) {
|
|
t.Fatalf("admin approver should grant %q: %+v", s, pAdm)
|
|
}
|
|
}
|
|
// Requested [mcp] ∩ approver [mcp, read] → [mcp].
|
|
dc3, uc3 := startScoped(t, f, "mcp")
|
|
// RequestedScopes reflects the original request (before consumption).
|
|
rq, ok := f.RequestedScopes(uc3)
|
|
if !ok || len(rq) != 1 || rq[0] != "mcp" {
|
|
t.Fatalf("requested = %v %v", rq, ok)
|
|
}
|
|
if err := f.Approve(uc3, &Principal{Subject: "admin", Scopes: []string{"mcp", "read"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok3, err := f.Token(dc3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p3, _ := f.jwt.Verify(tok3)
|
|
if len(p3.Scopes) != 1 || !p3.HasScope("mcp") {
|
|
t.Fatalf("intersection should grant mcp only: %+v", p3)
|
|
}
|
|
// Unknown code.
|
|
if _, ok := f.RequestedScopes("NOPE-XXXX"); ok {
|
|
t.Fatal("unknown code should not report scopes")
|
|
}
|
|
}
|
|
|
|
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)
|
|
if !strings.Contains(uri, "https://admin.example.com/device") {
|
|
t.Fatalf("verification_uri = %q", uri)
|
|
}
|
|
if !strings.Contains(complete, "code="+uc) {
|
|
t.Fatalf("verification_uri_complete = %q", complete)
|
|
}
|
|
if exp <= 0 || interval <= 0 {
|
|
t.Fatalf("expires_in=%d interval=%d", exp, interval)
|
|
}
|
|
// Pending before approval.
|
|
if _, err := f.Token(dc); !errors.Is(err, ErrAuthorizationPending) {
|
|
t.Fatalf("err = %v, want pending", err)
|
|
}
|
|
// Unknown code.
|
|
if err := f.Approve("NOPE-XXXX", &Principal{Subject: "u"}); err == nil {
|
|
t.Fatal("approve with unknown code should fail")
|
|
}
|
|
// Approve with a valid code format.
|
|
if err := f.Approve(uc, &Principal{Subject: "user-1", Scopes: []string{"mcp", "write"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, err := f.Token(dc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, err := f.jwt.Verify(tok)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p.Subject != "user-1" || !p.HasScope("mcp") || !p.HasScope("write") {
|
|
t.Fatalf("token principal = %+v", p)
|
|
}
|
|
// One-shot: second poll fails.
|
|
if _, err := f.Token(dc); !errors.Is(err, ErrDeviceUnknown) {
|
|
t.Fatalf("err = %v, want ErrDeviceUnknown after consumption", err)
|
|
}
|
|
}
|
|
|
|
func TestDeviceFlowExpiry(t *testing.T) {
|
|
now := time.Now()
|
|
f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{})
|
|
f.now = func() time.Time { return now }
|
|
dc, uc, _, _, _, _ := mustStart(t, f)
|
|
// Advance past TTL.
|
|
f.now = func() time.Time { return now.Add(11 * time.Minute) }
|
|
if err := f.Approve(uc, &Principal{Subject: "u"}); !errors.Is(err, ErrDeviceUnknown) {
|
|
t.Fatalf("approve expired = %v, want ErrDeviceUnknown", err)
|
|
}
|
|
if _, err := f.Token(dc); !errors.Is(err, ErrDeviceExpired) {
|
|
t.Fatalf("token expired = %v, want ErrDeviceExpired", err)
|
|
}
|
|
}
|
|
|
|
func TestDeviceFlowTokenDisabled(t *testing.T) {
|
|
f := NewDeviceFlow(NewLocalJWT(""), DeviceFlowOptions{})
|
|
dc, uc, _, _, _, _ := mustStart(t, f)
|
|
if err := f.Approve(uc, &Principal{Subject: "u"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.Token(dc); err == nil {
|
|
t.Fatal("token with disabled jwt should fail")
|
|
}
|
|
}
|
|
|
|
func TestDeviceFlowUserCodeFormat(t *testing.T) {
|
|
f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{})
|
|
_, uc, _, _, _, _ := mustStart(t, f)
|
|
if len(uc) != 9 || uc[4] != '-' {
|
|
t.Fatalf("user code %q must be XXXX-XXXX", uc)
|
|
}
|
|
for _, c := range uc {
|
|
if c == '-' {
|
|
continue
|
|
}
|
|
if !strings.ContainsRune(userCodeAlphabet, c) {
|
|
t.Fatalf("user code %q contains char outside alphabet", uc)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustStart(t *testing.T, f *DeviceFlow) (dc, uc, uri, complete string, exp, interval int) {
|
|
t.Helper()
|
|
dc, uc, uri, complete, exp, interval, err := f.Start()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return dc, uc, uri, complete, exp, interval
|
|
}
|
|
|
|
func mustStartScoped(t *testing.T, f *DeviceFlow, scopes ...string) (dc, uc string) {
|
|
t.Helper()
|
|
dc, uc, _, _, _, _, err := f.Start(scopes...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return dc, uc
|
|
}
|
|
|
|
func startScoped(t *testing.T, f *DeviceFlow, scopes ...string) (dc, uc string) {
|
|
t.Helper()
|
|
dc, uc, _, _, _, _, err := f.Start(scopes...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return dc, uc
|
|
}
|