test(09-02): add failing tests for the backend auth lifecycle
- Login does not stamp last_login and logout, refresh, and me are unmounted - Repeated logins are not throttled and auth events are not logged
This commit is contained in:
543
cabana/auth_test.go
Normal file
543
cabana/auth_test.go
Normal file
@@ -0,0 +1,543 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/cabana"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
"git.golem15.com/golem15/summercms/lagoon"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
"git.golem15.com/golem15/summercms/surf"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
adminTestSecret = "summercms-test-only-admin-hs256-secret"
|
||||
adminTestPassword = "lifecycle-secret"
|
||||
)
|
||||
|
||||
func TestAdminAuthLifecycle(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
user := insertAdmin(t, gdb, "life", "Life@Example.Test", adminTestPassword, true, false)
|
||||
login := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
"login": "life",
|
||||
"password": adminTestPassword,
|
||||
})
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", login.Code, login.Body.String())
|
||||
}
|
||||
token := accessToken(t, login.Body.Bytes())
|
||||
if aud := jwtAudience(t, token); aud != "backend" {
|
||||
t.Fatalf("aud=%q", aud)
|
||||
}
|
||||
if strings.Contains(login.Body.String(), adminTestPassword) {
|
||||
t.Fatal("login body contains the password")
|
||||
}
|
||||
var stamped sql.NullTime
|
||||
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE id = ?`, user.ID).Scan(&stamped).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stamped.Valid {
|
||||
t.Fatal("successful login did not stamp last_login")
|
||||
}
|
||||
byEmail := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
"email": "life@example.test",
|
||||
"password": adminTestPassword,
|
||||
})
|
||||
if byEmail.Code != http.StatusOK {
|
||||
t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String())
|
||||
}
|
||||
emailToken := accessToken(t, byEmail.Body.Bytes())
|
||||
me := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken)
|
||||
if me.Code != http.StatusOK {
|
||||
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
|
||||
}
|
||||
assertSafeProfile(t, me.Body.Bytes(), user)
|
||||
refreshed := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", emailToken, nil)
|
||||
if refreshed.Code != http.StatusOK {
|
||||
t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
|
||||
}
|
||||
next := accessToken(t, refreshed.Body.Bytes())
|
||||
if next == emailToken {
|
||||
t.Fatal("refresh returned the same token")
|
||||
}
|
||||
if jwtAudience(t, next) != "backend" {
|
||||
t.Fatal("refreshed token lost the backend audience")
|
||||
}
|
||||
oldMe := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken)
|
||||
if oldMe.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("previous token after refresh status=%d body=%s", oldMe.Code, oldMe.Body.String())
|
||||
}
|
||||
var blacklisted int
|
||||
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jwtID(t, emailToken)).Scan(&blacklisted).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blacklisted != 1 {
|
||||
t.Fatalf("previous jti blacklist rows=%d", blacklisted)
|
||||
}
|
||||
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", next, nil)
|
||||
if out.Code != http.StatusOK {
|
||||
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
||||
}
|
||||
if strings.Contains(out.Body.String(), next) {
|
||||
t.Fatal("logout body contains the token")
|
||||
}
|
||||
after := getAuth(t, h, "/_admin/api/v1/auth/me", next)
|
||||
if after.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("me after logout status=%d", after.Code)
|
||||
}
|
||||
cutoff := time.Now().Add(time.Minute)
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
"login": "life", "password": adminTestPassword,
|
||||
}).Body.Bytes())
|
||||
// Login mints after the cutoff, so this token is current. Move the cutoff
|
||||
// past it to prove a stale principal fails closed.
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(time.Hour)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := getAuth(t, h, "/_admin/api/v1/auth/me", fresh)
|
||||
if stale.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminInactive(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "inactive", "inactive@example.test", adminTestPassword, false, false)
|
||||
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody", "password": adminTestPassword})
|
||||
wrong := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": "wrong-password"})
|
||||
right := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": adminTestPassword})
|
||||
assertSameOpaque(t, unknown, wrong, right)
|
||||
var stamped sql.NullTime
|
||||
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE login = 'inactive'`).Scan(&stamped).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stamped.Valid {
|
||||
t.Fatal("inactive login stamped last_login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDeleted(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "deleted", "deleted@example.test", adminTestPassword, true, true)
|
||||
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody-else", "password": adminTestPassword})
|
||||
deleted := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "deleted", "password": adminTestPassword})
|
||||
assertSameOpaque(t, unknown, deleted)
|
||||
if strings.Contains(strings.ToLower(deleted.Body.String()), "delet") {
|
||||
t.Fatalf("deleted login disclosed the account: %s", deleted.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminBlacklist(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "revoke", "revoke@example.test", adminTestPassword, true, false)
|
||||
token := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
"login": "revoke", "password": adminTestPassword,
|
||||
}).Body.Bytes())
|
||||
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", token, nil)
|
||||
if out.Code != http.StatusOK {
|
||||
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
||||
}
|
||||
again := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", token, nil)
|
||||
if again.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String())
|
||||
}
|
||||
var n int
|
||||
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jwtID(t, token)).Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("admin blacklist rows=%d, want the revoked jti in backend_jwt_blacklist", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginThrottle(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, func(cfg *compass.Config) {
|
||||
if err := cfg.Set("admin.login.max_attempts", 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.Set("admin.login.decay_minutes", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
var last *httptest.ResponseRecorder
|
||||
for i := 0; i < 3; i++ {
|
||||
last = postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
"login": "throttle-user", "password": adminTestPassword,
|
||||
})
|
||||
}
|
||||
if last.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("third login status=%d body=%s, want 429 from the fixed-window limiter", last.Code, last.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAuthLogging(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
user := insertAdmin(t, gdb, "logged", "logged@example.test", adminTestPassword, true, false)
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
ok := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": adminTestPassword})
|
||||
token := accessToken(t, ok.Body.Bytes())
|
||||
assertLog(t, &buf, "success", user.ID, adminTestPassword, token)
|
||||
buf.Reset()
|
||||
|
||||
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": "not-the-password"})
|
||||
assertLog(t, &buf, "failed", user.ID, "not-the-password", "")
|
||||
buf.Reset()
|
||||
|
||||
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "missing-logged", "password": "not-the-password"})
|
||||
failedUnknown := buf.String()
|
||||
if !strings.Contains(failedUnknown, `"outcome":"failed"`) {
|
||||
t.Fatalf("unknown login log = %s", failedUnknown)
|
||||
}
|
||||
if strings.Contains(failedUnknown, `"admin_id"`) || strings.Contains(failedUnknown, "not-the-password") {
|
||||
t.Fatalf("unknown login log disclosed an id or password: %s", failedUnknown)
|
||||
}
|
||||
buf.Reset()
|
||||
|
||||
denied := getAuth(t, h, "/_admin/api/v1/acme/demo/widgets", token)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String())
|
||||
}
|
||||
assertLog(t, &buf, "denied", user.ID, adminTestPassword, token)
|
||||
}
|
||||
|
||||
func assertLog(t *testing.T, buf *bytes.Buffer, outcome string, id uint, secret, token string) {
|
||||
t.Helper()
|
||||
text := buf.String()
|
||||
if !strings.Contains(text, `"outcome":"`+outcome+`"`) || !strings.Contains(text, `"admin_id":`+itoa(id)) {
|
||||
t.Fatalf("log %q missing outcome %s admin %d", text, outcome, id)
|
||||
}
|
||||
if secret != "" && strings.Contains(text, secret) {
|
||||
t.Fatalf("log contains a credential: %s", text)
|
||||
}
|
||||
if token != "" && strings.Contains(text, token) {
|
||||
t.Fatalf("log contains a bearer token: %s", text)
|
||||
}
|
||||
if strings.Contains(text, "$2a$") || strings.Contains(text, adminTestSecret) {
|
||||
t.Fatalf("log contains a hash or signing secret: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSameOpaque(t *testing.T, recs ...*httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("no responses")
|
||||
}
|
||||
want := recs[0].Body.String()
|
||||
for _, rec := range recs {
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != want {
|
||||
t.Fatalf("opaque mismatch %d %s vs %s", rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Error.Code != "unauthenticated" || body.Error.Message == "" {
|
||||
t.Fatalf("error = %+v", body.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSafeProfile(t *testing.T, raw []byte, user cabana.BackendUser) {
|
||||
t.Helper()
|
||||
if strings.Contains(string(raw), adminTestPassword) || strings.Contains(string(raw), "permissions") || strings.Contains(string(raw), "$2") {
|
||||
t.Fatalf("profile leaked sensitive data: %s", raw)
|
||||
}
|
||||
var body struct {
|
||||
Data struct {
|
||||
ID uint `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
IsSuperuser bool `json:"is_superuser"`
|
||||
Role struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
} `json:"role"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Data.ID != user.ID || body.Data.Login != user.Login || body.Data.Email != user.Email || body.Data.FirstName != "Ada" || body.Data.LastName != "Admin" || body.Data.IsSuperuser || body.Data.Role.Code != "developer" || body.Data.Role.Name != "Developer" {
|
||||
t.Fatalf("profile = %+v", body.Data)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
adminOnce sync.Once
|
||||
adminSQL *sql.DB
|
||||
adminGDB *gorm.DB
|
||||
adminErr error
|
||||
)
|
||||
|
||||
func adminGorm(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("requires testcontainers postgres")
|
||||
}
|
||||
adminOnce.Do(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
|
||||
postgres.WithDatabase("cabana"),
|
||||
postgres.WithUsername("cabana"),
|
||||
postgres.WithPassword("cabana"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
testcontainers.WithEnv(map[string]string{
|
||||
"POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8",
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
adminErr = err
|
||||
return
|
||||
}
|
||||
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
adminErr = err
|
||||
return
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
adminErr = err
|
||||
return
|
||||
}
|
||||
gdb, err := lagoon.Use(ctx, db)
|
||||
if err != nil {
|
||||
adminErr = err
|
||||
return
|
||||
}
|
||||
if err := lagoon.Migrate(gdb, nil); err != nil {
|
||||
adminErr = err
|
||||
return
|
||||
}
|
||||
adminSQL = db
|
||||
adminGDB = gdb
|
||||
})
|
||||
if adminErr != nil {
|
||||
t.Fatalf("postgres: %v", adminErr)
|
||||
}
|
||||
return adminGDB
|
||||
}
|
||||
|
||||
func adminHandler(t *testing.T, gdb *gorm.DB, mutate func(*compass.Config)) http.Handler {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-auth\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_ENV=development",
|
||||
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(cfg)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := surf.Assemble(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func insertAdmin(t *testing.T, gdb *gorm.DB, login, email, password string, activated, deleted bool) cabana.BackendUser {
|
||||
t.Helper()
|
||||
hash, err := bouncer.HashPassword(10, password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var roleID uint
|
||||
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 {
|
||||
t.Fatalf("developer role: %v id=%d", err, roleID)
|
||||
}
|
||||
user := cabana.BackendUser{
|
||||
FirstName: "Ada",
|
||||
LastName: "Admin",
|
||||
Login: login,
|
||||
Email: email,
|
||||
Password: hash,
|
||||
IsActivated: activated,
|
||||
RoleID: &roleID,
|
||||
}
|
||||
if err := gdb.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted {
|
||||
if err := gdb.Delete(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
type demoPlugin struct{ fsys fs.FS }
|
||||
|
||||
func (p demoPlugin) ID() string { return "acme.demo" }
|
||||
func (p demoPlugin) Requires() []string { return nil }
|
||||
func (p demoPlugin) Register(*backpack.App) error { return nil }
|
||||
func (p demoPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p demoPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{demoController{}}
|
||||
}
|
||||
func (p demoPlugin) AdminFS() fs.FS { return p.fsys }
|
||||
|
||||
type demoController struct{}
|
||||
|
||||
func (demoController) ID() string { return "acme.demo.widgets" }
|
||||
func (demoController) ModelName() string { return "Widget" }
|
||||
func (demoController) ConfigDir() string { return "controllers/widgets" }
|
||||
func (demoController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
|
||||
|
||||
func demoFS() fs.FS {
|
||||
return fstest.MapFS{
|
||||
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")},
|
||||
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")},
|
||||
}
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return postAuth(t, h, http.MethodPost, path, "", raw)
|
||||
}
|
||||
|
||||
func postAuth(t *testing.T, h http.Handler, method, path, token string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func getAuth(t *testing.T, h http.Handler, path, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return postAuth(t, h, http.MethodGet, path, token, nil)
|
||||
}
|
||||
|
||||
func accessToken(t *testing.T, raw []byte) string {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("token json: %v body=%s", err, raw)
|
||||
}
|
||||
if body.Data.AccessToken == "" {
|
||||
t.Fatalf("missing access_token: %s", raw)
|
||||
}
|
||||
return body.Data.AccessToken
|
||||
}
|
||||
|
||||
func jwtAudience(t *testing.T, token string) string {
|
||||
t.Helper()
|
||||
claims := jwtClaims(t, token)
|
||||
switch v := claims["aud"].(type) {
|
||||
case string:
|
||||
return v
|
||||
case []any:
|
||||
if len(v) == 1 {
|
||||
s, _ := v[0].(string)
|
||||
return s
|
||||
}
|
||||
}
|
||||
t.Fatalf("aud=%v", claims["aud"])
|
||||
return ""
|
||||
}
|
||||
|
||||
func jwtID(t *testing.T, token string) string {
|
||||
t.Helper()
|
||||
id, _ := jwtClaims(t, token)["jti"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("token has no jti")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func jwtClaims(t *testing.T, token string) map[string]any {
|
||||
t.Helper()
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("token segments=%d", len(parts))
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(raw, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func itoa(id uint) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
Reference in New Issue
Block a user