Files
sm-user-plugin/device_flow_smoke_test.go
Jakub Zych 4e3e5b05b5 feat: device authorization by QR or short code
PHP's device flow on golem15_user_device_auth_sessions (PHP's columns,
the token stored as its SHA-256, plus jwt_jti): POST /devices/initiate
answers a 64-character token, an XXXX-XXXX short code, the configurable
auth_url (golem15.user.device_auth.auth_url) and the expiry; a signed-in
user authorizes it with POST /devices/authorize (short_code or token,
one guarded pending-to-confirmed UPDATE); the device's first poll of
GET /devices/status/{token} after that receives a session JWT through one
guarded confirmed-to-used UPDATE, so exactly one poll gets it. Used and
revoked sessions never turn into expired; each initiate deletes a bounded
batch of stale sessions.
2026-10-26 10:14:48 +01:00

321 lines
12 KiB
Go

package user
import (
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
var deviceShortCodeRE = regexp.MustCompile(`^[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`)
// TestDeviceCodesSmoke pins the device token, the short code, its
// normalisation and the auth_url template. No database.
func TestDeviceCodesSmoke(t *testing.T) {
seen := map[string]bool{}
for range 1000 {
tok, err := classes.NewDeviceToken()
if err != nil {
t.Fatal(err)
}
if len(tok) != 64 || strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" {
t.Fatalf("token %q is not 64 base64url characters", tok)
}
if seen[tok] {
t.Fatalf("token %q repeated", tok)
}
seen[tok] = true
code, err := classes.NewDeviceShortCode()
if err != nil {
t.Fatal(err)
}
if !deviceShortCodeRE.MatchString(code) {
t.Fatalf("short code %q", code)
}
}
for in, want := range map[string]string{
"abcd-efgh": "ABCD-EFGH",
"ABCDEFGH": "ABCD-EFGH",
" ab cd ef gh ": "ABCD-EFGH",
"2345-6789": "2345-6789",
} {
if got, ok := classes.NormalizeDeviceShortCode(in); !ok || got != want {
t.Fatalf("normalize %q = %q %v, want %q", in, got, ok, want)
}
}
for _, in := range []string{"ABCDEFG", "ABCDEFGHJ", "ABCDEFGO", "ABCDEFGI", "ABCDEFGL", "ABCDEFG0", "ABCDEFG1", ""} {
if got, ok := classes.NormalizeDeviceShortCode(in); ok {
t.Fatalf("normalize %q accepted as %q", in, got)
}
}
app := backpack.New(sessionConfig(t))
if got := classes.DeviceAuthURL(app, "tok"); got != "/device/scan/tok" {
t.Fatalf("auth_url without urls = %q", got)
}
if err := app.Config.Set("app.url", "https://api.example.test/"); err != nil {
t.Fatal(err)
}
if got := classes.DeviceAuthURL(app, "tok"); got != "https://api.example.test/device/scan/tok" {
t.Fatalf("auth_url from app.url = %q", got)
}
if err := app.Config.Set("golem15.user.frontend_url", "https://www.example.test/"); err != nil {
t.Fatal(err)
}
if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/device/scan/tok" {
t.Fatalf("auth_url from frontend_url = %q", got)
}
if err := app.Config.Set("golem15.user.device_auth.auth_url", "{frontend_url}/link?device={token}"); err != nil {
t.Fatal(err)
}
if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/link?device=tok" {
t.Fatalf("auth_url from a custom template = %q", got)
}
if got := classes.DeviceAuthTTL(app); got != 5*time.Minute {
t.Fatalf("default ttl = %s", got)
}
tv := "Living room TV"
for _, c := range []struct {
stored *string
ua string
want string
}{
{&tv, "Mozilla/5.0 (iPhone)", tv},
{nil, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", "iPhone"},
{nil, "Mozilla/5.0 (iPad; CPU OS 17_0)", "iPad"},
{nil, "Mozilla/5.0 (Linux; Android 14)", "Android Device"},
{nil, "Mozilla/5.0 (Windows NT 10.0)", "Windows PC"},
{nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", "Mac"},
{nil, "Mozilla/5.0 (X11; Linux x86_64)", "Linux PC"},
{nil, "curl/8.0", "Unknown Device"},
} {
if got := classes.DeviceDisplayName(c.stored, c.ua); got != c.want {
t.Fatalf("display name of %q = %q, want %q", c.ua, got, c.want)
}
}
}
// deviceCall runs one device handler with an optional bearer token and JSON
// body; pathToken fills {token} or {id}.
func deviceCall(t *testing.T, h http.HandlerFunc, method, path, bearerToken, body string, pathValues map[string]string) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
req.Host = "example.com"
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) SmokeTV")
if bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
for k, v := range pathValues {
req.SetPathValue(k, v)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
type deviceInitiated struct {
Token string `json:"token"`
ShortCode string `json:"short_code"`
AuthURL string `json:"auth_url"`
ExpiresAt string `json:"expires_at"`
}
func initiateDevice(t *testing.T, app *backpack.App, name string) deviceInitiated {
t.Helper()
body := `{}`
if name != "" {
body = `{"device_name":"` + name + `"}`
}
rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", body, nil)
if rec.Code != http.StatusOK {
t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String())
}
var out struct {
Success bool `json:"success"`
Data deviceInitiated `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || !out.Success {
t.Fatalf("initiate body %s: %v", rec.Body.String(), err)
}
return out.Data
}
func deviceStatus(t *testing.T, app *backpack.App, token string) (int, map[string]any) {
t.Helper()
rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+token, "", "", map[string]string{"token": token})
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
return rec.Code, body
}
func authorizeDevice(t *testing.T, app *backpack.App, bearerToken, body string) *httptest.ResponseRecorder {
t.Helper()
return deviceCall(t, controllers.DeviceAuthorize(app), http.MethodPost, "/_user/api/v1/devices/authorize", bearerToken, body, nil)
}
func deviceRow(t *testing.T, gdb *gorm.DB, token string) models.DeviceAuthSession {
t.Helper()
var row models.DeviceAuthSession
if err := gdb.Where("token = ?", classes.HashAPIToken(token)).Take(&row).Error; err != nil {
t.Fatal(err)
}
return row
}
func statusData(body map[string]any) map[string]any {
data, _ := body["data"].(map[string]any)
return data
}
// TestDeviceFlowSmoke drives initiate, authorize and the one-time hand-over
// on Postgres.
func TestDeviceFlowSmoke(t *testing.T) {
if testing.Short() {
t.Skip("Postgres (testcontainers)")
}
app, gdb := sessionApp(t, "0s")
email := "device-owner-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.test"
session := loginToken(t, app, gdb, email)
var owner models.User
if err := gdb.Where("email = ?", email).Take(&owner).Error; err != nil {
t.Fatal(err)
}
dev := initiateDevice(t, app, "Living room TV")
if len(dev.Token) != 64 || !deviceShortCodeRE.MatchString(dev.ShortCode) || !strings.Contains(dev.AuthURL, dev.Token) || !strings.HasSuffix(dev.ExpiresAt, "+00:00") {
t.Fatalf("initiate data = %+v", dev)
}
row := deviceRow(t, gdb, dev.Token)
if row.TokenHash != classes.HashAPIToken(dev.Token) || row.Status != models.DeviceStatusPending || row.UserID != nil ||
row.DeviceName == nil || *row.DeviceName != "Living room TV" || row.DeviceUserAgent == nil {
t.Fatalf("stored row = %+v", row)
}
if code, body := deviceStatus(t, app, dev.Token); code != http.StatusOK || body["success"] != true || statusData(body)["status"] != "pending" {
t.Fatalf("pending status = %d %v", code, body)
}
if code, body := deviceStatus(t, app, strings.Repeat("z", 64)); code != http.StatusNotFound || body["error"] != "Session not found" {
t.Fatalf("unknown status = %d %v", code, body)
}
// Authorize needs a session, answering PHP's 401 body.
if rec := authorizeDevice(t, app, "", `{"short_code":"`+dev.ShortCode+`"}`); rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Unauthorized"}` {
t.Fatalf("authorize without a session = %d %s", rec.Code, rec.Body.String())
}
if rec := authorizeDevice(t, app, session, `{"short_code":"ABC"}`); rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != `{"error":"Please enter a valid 8-character code"}` {
t.Fatalf("short code of 3 = %d %s", rec.Code, rec.Body.String())
}
lower := strings.ToLower(dev.ShortCode)
if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Device authorized successfully","success":true}` {
t.Fatalf("authorize = %d %s", rec.Code, rec.Body.String())
}
if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK {
t.Fatalf("authorizing again for the same user = %d %s", rec.Code, rec.Body.String())
}
// The first poll after confirmation receives the token, once.
code, body := deviceStatus(t, app, dev.Token)
data := statusData(body)
if code != http.StatusOK || data["status"] != "confirmed" || data["message"] != "Device authorized - complete login" || data["user_id"] != float64(owner.ID) {
t.Fatalf("hand-over = %d %v", code, body)
}
jwtToken, _ := data["token"].(string)
sub, _, _, jti, err := bouncer.VerifyClaims(jwtToken, sessionSecret)
if err != nil || sub != strconv.FormatUint(uint64(owner.ID), 10) {
t.Fatalf("device token sub=%q err=%v", sub, err)
}
if user, _ := data["user"].(map[string]any); user["email"] != email {
t.Fatalf("hand-over user = %v", data["user"])
}
row = deviceRow(t, gdb, dev.Token)
if row.Status != models.DeviceStatusUsed || row.JWTJTI == nil || *row.JWTJTI != jti || row.UsedAt == nil {
t.Fatalf("row after hand-over = %+v", row)
}
if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" || statusData(body)["token"] != nil {
t.Fatalf("second poll = %v", body)
}
// The used session is past nothing: even past expires_at it stays used.
if err := gdb.Model(&models.DeviceAuthSession{}).Where("id = ?", row.ID).Update("expires_at", time.Now().Add(-time.Hour)).Error; err != nil {
t.Fatal(err)
}
if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" {
t.Fatalf("used session past expires_at = %v", body)
}
// A used session cannot be authorized again.
if rec := authorizeDevice(t, app, session, `{"token":"`+dev.Token+`"}`); rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"Invalid or expired code"}` {
t.Fatalf("authorize a used session = %d %s", rec.Code, rec.Body.String())
}
// Ten concurrent polls of a confirmed session yield exactly one token.
race := initiateDevice(t, app, "")
if rec := authorizeDevice(t, app, session, `{"token":"`+race.Token+`"}`); rec.Code != http.StatusOK {
t.Fatalf("authorize by token = %d %s", rec.Code, rec.Body.String())
}
var (
wg sync.WaitGroup
mu sync.Mutex
tokens int
used int
)
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
_, body := deviceStatus(t, app, race.Token)
d := statusData(body)
mu.Lock()
defer mu.Unlock()
if tok, _ := d["token"].(string); tok != "" {
tokens++
} else if d["status"] == "used" {
used++
}
}()
}
wg.Wait()
if tokens != 1 || used != 9 {
t.Fatalf("concurrent polls: %d tokens, %d used", tokens, used)
}
// A pending session past expires_at answers PHP's flat expired body.
stale := initiateDevice(t, app, "")
if err := gdb.Model(&models.DeviceAuthSession{}).Where("token = ?", classes.HashAPIToken(stale.Token)).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
t.Fatal(err)
}
if rec := authorizeDevice(t, app, session, `{"short_code":"`+stale.ShortCode+`"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("authorize an expired session = %d %s", rec.Code, rec.Body.String())
}
rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+stale.Token, "", "", map[string]string{"token": stale.Token})
if rec.Body.String() != `{"message":"Authorization session has expired","status":"expired","success":true}` {
t.Fatalf("expired status = %d %s", rec.Code, rec.Body.String())
}
if row := deviceRow(t, gdb, stale.Token); row.Status != models.DeviceStatusExpired {
t.Fatalf("expired row status = %s", row.Status)
}
// device_name is bounded.
long := strings.Repeat("n", 256)
if rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", `{"device_name":"`+long+`"}`, nil); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("device_name of 256 = %d %s", rec.Code, rec.Body.String())
}
}