Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 4 additions & 3 deletions internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ type DeviceTokens struct {
}

// ExchangeDeviceCode trades a one-time device-auth code for a JWT pair.
// The code is the credential — no prior auth is required.
func (c *Client) ExchangeDeviceCode(ctx context.Context, code string) (*DeviceTokens, error) {
// The code is the credential — no prior auth is required. The verifier is
// the PKCE code verifier whose S256 challenge was sent on the login URL.
func (c *Client) ExchangeDeviceCode(ctx context.Context, code, verifier string) (*DeviceTokens, error) {
var out DeviceTokens
if err := c.do(ctx, http.MethodPost, "/auth/device/token", nil, map[string]string{"code": code}, &out); err != nil {
if err := c.do(ctx, http.MethodPost, "/auth/device/token", nil, map[string]string{"code": code, "code_verifier": verifier}, &out); err != nil {
return nil, err
}
return &out, nil
Expand Down
13 changes: 12 additions & 1 deletion internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
Expand All @@ -12,12 +13,22 @@ func TestExchangeDeviceCode(t *testing.T) {
if r.URL.Path != "/auth/device/token" || r.Method != http.MethodPost {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode body: %v", err)
}
if body["code"] != "onetimecode" {
t.Errorf("code = %q, want onetimecode", body["code"])
}
if body["code_verifier"] != "theverifier" {
t.Errorf("code_verifier = %q, want theverifier", body["code_verifier"])
}
w.Write([]byte(`{"access_token":"at","refresh_token":"rt","user":{"id":"1","email":"a@b.c","email_verified":true,"name":"A","plan":"free"}}`))
}))
defer srv.Close()

c := New(srv.URL, newTestStore(t, nil))
tok, err := c.ExchangeDeviceCode(context.Background(), "onetimecode")
tok, err := c.ExchangeDeviceCode(context.Background(), "onetimecode", "theverifier")
if err != nil {
t.Fatal(err)
}
Expand Down
43 changes: 34 additions & 9 deletions internal/auth/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
Expand Down Expand Up @@ -34,16 +36,21 @@ type DeviceFlow struct {
}

// Run blocks until the consent callback delivers a code, the context
// expires, or the callback is invalid. Returns the one-time code.
func (f *DeviceFlow) Run(ctx context.Context) (string, error) {
// expires, or the callback is invalid. Returns the one-time code and the
// PKCE verifier that must accompany it in the token exchange.
func (f *DeviceFlow) Run(ctx context.Context) (string, string, error) {
state, err := randomState()
if err != nil {
return "", err
return "", "", err
}
verifier, err := codeVerifier()
if err != nil {
return "", "", err
}

ln, err := net.Listen("tcp", CallbackAddr)
if err != nil {
return "", fmt.Errorf("cannot listen on %s (is another spoo login running?): %w", CallbackAddr, err)
return "", "", fmt.Errorf("cannot listen on %s (is another spoo login running?): %w", CallbackAddr, err)
}

codeCh := make(chan string, 1)
Expand Down Expand Up @@ -72,9 +79,10 @@ func (f *DeviceFlow) Run(ctx context.Context) (string, error) {
go srv.Serve(ln)
defer srv.Shutdown(context.Background())

authURL := fmt.Sprintf("%s/auth/device/login?app_id=%s&redirect_uri=%s&state=%s",
authURL := fmt.Sprintf("%s/auth/device/login?app_id=%s&redirect_uri=%s&state=%s&code_challenge=%s&code_challenge_method=S256",
f.APIBase, AppID,
url.QueryEscape("http://"+CallbackAddr+CallbackPath), state)
url.QueryEscape("http://"+CallbackAddr+CallbackPath), state,
codeChallengeS256(verifier))

fmt.Fprintln(f.Out, "Opening your browser to authorize spoo CLI…")
fmt.Fprintf(f.Out, "If it doesn't open automatically, visit:\n\n %s\n\n", authURL)
Expand All @@ -84,11 +92,11 @@ func (f *DeviceFlow) Run(ctx context.Context) (string, error) {

select {
case code := <-codeCh:
return code, nil
return code, verifier, nil
case err := <-errCh:
return "", err
return "", "", err
case <-ctx.Done():
return "", fmt.Errorf("login timed out: %w", ctx.Err())
return "", "", fmt.Errorf("login timed out: %w", ctx.Err())
}
}

Expand All @@ -99,3 +107,20 @@ func randomState() (string, error) {
}
return hex.EncodeToString(b), nil
}

// codeVerifier returns a PKCE code verifier: 32 random bytes encoded as
// unpadded base64url, always 43 characters (RFC 7636 §4.1).
func codeVerifier() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}

// codeChallengeS256 derives the S256 challenge for a verifier:
// BASE64URL(SHA256(verifier)) without padding (RFC 7636 §4.2).
func codeChallengeS256(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
33 changes: 31 additions & 2 deletions internal/auth/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"testing"
"time"
)

var challengeRe = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)

// Simulates the browser leg: the flow opens a URL; we parse state and
// redirect_uri out of it and hit the loopback callback like spoo.me would.
func TestDeviceFlowReturnsCode(t *testing.T) {
challengeCh := make(chan string, 1)
flow := &DeviceFlow{
APIBase: "https://spoo.example",
Out: io.Discard,
Expand All @@ -32,6 +36,14 @@ func TestDeviceFlowReturnsCode(t *testing.T) {
if !strings.HasPrefix(cb, "http://127.0.0.1:53682/callback") {
t.Errorf("redirect_uri = %q", cb)
}
if q.Get("code_challenge_method") != "S256" {
t.Errorf("code_challenge_method = %q, want S256", q.Get("code_challenge_method"))
}
challenge := q.Get("code_challenge")
if !challengeRe.MatchString(challenge) {
t.Errorf("code_challenge = %q, want 43 base64url chars", challenge)
}
challengeCh <- challenge
time.Sleep(50 * time.Millisecond) // let the server start
resp, err := http.Get(fmt.Sprintf("%s?code=thecode&state=%s", cb, q.Get("state")))
if err != nil {
Expand All @@ -46,13 +58,30 @@ func TestDeviceFlowReturnsCode(t *testing.T) {

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
code, err := flow.Run(ctx)
code, verifier, err := flow.Run(ctx)
if err != nil {
t.Fatal(err)
}
if code != "thecode" {
t.Fatalf("code = %q, want thecode", code)
}
if len(verifier) != 43 {
t.Fatalf("verifier length = %d, want 43", len(verifier))
}
if got := codeChallengeS256(verifier); got != <-challengeCh {
t.Fatalf("code_challenge on auth URL does not match S256(verifier): %q", got)
}
}

// RFC 7636 Appendix B test vector.
func TestCodeChallengeS256Vector(t *testing.T) {
const (
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
want = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
)
if got := codeChallengeS256(verifier); got != want {
t.Fatalf("codeChallengeS256 = %q, want %q", got, want)
}
}

func TestDeviceFlowRejectsStateMismatch(t *testing.T) {
Expand All @@ -72,7 +101,7 @@ func TestDeviceFlowRejectsStateMismatch(t *testing.T) {
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := flow.Run(ctx); err == nil {
if _, _, err := flow.Run(ctx); err == nil {
t.Fatal("expected state-mismatch error, got nil")
}
}
4 changes: 2 additions & 2 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ func loginWithBrowser(cmd *cobra.Command, d *deps) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
defer cancel()

code, err := flow.Run(ctx)
code, verifier, err := flow.Run(ctx)
if err != nil {
return err
}
tokens, err := d.client.ExchangeDeviceCode(ctx, code)
tokens, err := d.client.ExchangeDeviceCode(ctx, code, verifier)
if err != nil {
return fmt.Errorf("token exchange failed: %w", err)
}
Expand Down
Loading