Embed the userservice and add serve/agent subcommands for one-binary operation

This commit is contained in:
Emil
2026-08-02 19:49:54 +03:00
parent 9883def0c2
commit 1473bbe2a8
38 changed files with 3638 additions and 9 deletions
@@ -0,0 +1,72 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
)
// Claims is the payload of a signed token. Subject (from RegisteredClaims) is
// the user id — it becomes the coordinator's jobs.owner_id; Role drives
// authorization; Verified tells the coordinator whether this user's workers are
// trusted (results accepted without quorum). Both services verify this token
// locally with the shared HS256 secret, so no runtime call back to the
// userservice is ever needed.
type Claims struct {
Role domain.Role `json:"role"`
Verified bool `json:"verified"`
jwt.RegisteredClaims
}
// Issuer signs and verifies tokens with a shared HS256 secret.
type Issuer struct {
secret []byte
ttl time.Duration
now func() time.Time
}
// NewIssuer builds an Issuer. now defaults to time.Now when nil; tests inject a
// fixed clock to make expiry deterministic.
func NewIssuer(secret string, ttl time.Duration, now func() time.Time) Issuer {
if now == nil {
now = time.Now
}
return Issuer{secret: []byte(secret), ttl: ttl, now: now}
}
// Issue returns a signed token for the user, valid for the configured TTL. It
// takes the whole user so every trust-bearing field (role, verified) travels in
// the token, keeping the two services from needing a runtime lookup.
func (i Issuer) Issue(u *domain.User) (string, error) {
now := i.now()
claims := Claims{
Role: u.Role,
Verified: u.Verified,
RegisteredClaims: jwt.RegisteredClaims{
Subject: u.ID.String(),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret)
}
// Verify checks the signature and expiry and returns the claims. It pins the
// algorithm to HMAC, rejecting a token that asks for "none" or an RS256 public
// key — the classic algorithm-substitution attack against naive verifiers.
func (i Issuer) Verify(token string) (*Claims, error) {
var claims Claims
_, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return i.secret, nil
})
if err != nil {
return nil, err
}
return &claims, nil
}
@@ -0,0 +1,77 @@
package auth
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
)
const testSecret = "test-secret-at-least-32-bytes-long!!"
func TestIssueVerifyRoundTrip(t *testing.T) {
iss := NewIssuer(testSecret, time.Hour, nil)
id := uuid.New()
token, err := iss.Issue(&domain.User{ID: id, Role: domain.RoleAdmin, Verified: true})
if err != nil {
t.Fatalf("issue: %v", err)
}
claims, err := iss.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.Subject != id.String() {
t.Errorf("sub = %q, want %q", claims.Subject, id.String())
}
if claims.Role != domain.RoleAdmin {
t.Errorf("role = %q, want admin", claims.Role)
}
if !claims.Verified {
t.Error("verified claim not carried in token")
}
}
func TestVerifyRejectsExpired(t *testing.T) {
// Negative TTL: the token is already expired when issued.
iss := NewIssuer(testSecret, -time.Minute, nil)
token, _ := iss.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
if _, err := iss.Verify(token); err == nil {
t.Error("expired token accepted")
}
}
func TestVerifyRejectsWrongSecret(t *testing.T) {
token, _ := NewIssuer(testSecret, time.Hour, nil).Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
other := NewIssuer("another-secret-also-32-bytes-long!!!", time.Hour, nil)
if _, err := other.Verify(token); err == nil {
t.Error("token verified under the wrong secret")
}
}
func TestVerifyRejectsNoneAlgorithm(t *testing.T) {
// Forge a token signed with "none" — the classic algorithm-substitution
// attack. A verifier that trusts the header's alg would accept it.
tok := jwt.NewWithClaims(jwt.SigningMethodNone, Claims{
Role: domain.RoleAdmin,
RegisteredClaims: jwt.RegisteredClaims{
Subject: uuid.New().String(),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
})
raw, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("sign none: %v", err)
}
iss := NewIssuer(testSecret, time.Hour, nil)
if _, err := iss.Verify(raw); err == nil {
t.Error("none-signed token accepted")
}
}
@@ -0,0 +1,40 @@
// Package auth holds the cryptographic adapters — password hashing and JWT
// signing/verification. They implement use-case ports and keep bcrypt and the
// JWT library out of the domain and use-case layers.
package auth
import "golang.org/x/crypto/bcrypt"
// Hasher turns plaintext passwords into storable hashes and checks them back.
type Hasher struct {
cost int
}
// NewHasher builds a Hasher. A cost of 0 uses bcrypt's default work factor.
func NewHasher(cost int) Hasher {
if cost == 0 {
cost = bcrypt.DefaultCost
}
return Hasher{cost: cost}
}
// Hash returns the bcrypt hash of password. The salt and the cost are embedded
// in the returned string, so nothing else needs to be stored alongside it.
//
// bcrypt silently ignores input past 72 bytes; the use case rejects longer
// passwords before reaching here so a truncated tail never becomes a security
// surprise.
func (h Hasher) Hash(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), h.cost)
if err != nil {
return "", err
}
return string(b), nil
}
// Compare reports whether password matches the stored hash. It returns a
// non-nil error (bcrypt.ErrMismatchedHashAndPassword) on any mismatch, which
// the caller collapses into a generic authentication failure.
func (h Hasher) Compare(hash, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
@@ -0,0 +1,30 @@
package auth
import "testing"
func TestHashAndCompare(t *testing.T) {
h := NewHasher(0) // default cost
hash, err := h.Hash("correct horse battery staple")
if err != nil {
t.Fatalf("hash: %v", err)
}
if hash == "correct horse battery staple" {
t.Fatal("hash must not equal the plaintext")
}
if err := h.Compare(hash, "correct horse battery staple"); err != nil {
t.Errorf("correct password rejected: %v", err)
}
if err := h.Compare(hash, "wrong password"); err == nil {
t.Error("wrong password accepted")
}
}
func TestHashSaltsEachTime(t *testing.T) {
h := NewHasher(0)
a, _ := h.Hash("same")
b, _ := h.Hash("same")
if a == b {
t.Error("two hashes of the same password must differ (random salt)")
}
}