Hub: accounts, IMVU linking by tagline, proxy sign-in, presence, console
CI / test (push) Failing after 42s
CI / test (push) Failing after 42s
People sign up on the website and link IMVU accounts by putting a code in the account's tagline, which the hub reads from IMVU's public API (the most recent proof wins; link caps per person). IMVU's age verification or Access Pass make an account adult. T5DE's proxy signs in with a device code and keeps a WebSocket open, reporting the IMVU account in use and the community products it wears; /online shows those who opted in. Moderators get a console: who's online, people, link caps, bans, roles, and the audit log. Tests run the website and API against an embedded Postgres and a fake IMVU API. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
54e569bea3
commit
b9dc3f0ec1
36 files changed
+3548
-5
No files matched your search
@@ -0,0 +1,117 @@
|
||||
// Package token issues and checks the hub's access tokens: JWTs signed with Ed25519 ("EdDSA"), short-lived. The
|
||||
// public keys are published (JWKS), so catalogs can check tokens without asking the hub.
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Claims are what a token says.
|
||||
type Claims struct {
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"` // "person:<id>" for the hub's own API
|
||||
Audience string `json:"aud"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
Expires int64 `json:"exp"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// Keys signs and checks tokens.
|
||||
type Keys struct {
|
||||
id string
|
||||
private ed25519.PrivateKey
|
||||
issuer string
|
||||
}
|
||||
|
||||
// LoadOrCreate reads the signing key from path, making one if there's none.
|
||||
func LoadOrCreate(path, issuer string) (*Keys, error) {
|
||||
seed, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
seed = make([]byte, ed25519.SeedSize)
|
||||
if _, err := rand.Read(seed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, seed, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("%s: not an Ed25519 seed", path)
|
||||
}
|
||||
key := ed25519.NewKeyFromSeed(seed)
|
||||
pub := key.Public().(ed25519.PublicKey)
|
||||
return &Keys{id: b64(pub[:8]), private: key, issuer: issuer}, nil
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// Issue signs a token for subject and audience, valid for ttl.
|
||||
func (k *Keys) Issue(subject, audience, scope string, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
header, _ := json.Marshal(map[string]string{"alg": "EdDSA", "typ": "JWT", "kid": k.id})
|
||||
claims, _ := json.Marshal(Claims{Issuer: k.issuer, Subject: subject, Audience: audience, IssuedAt: now.Unix(),
|
||||
Expires: now.Add(ttl).Unix(), Scope: scope})
|
||||
signing := b64(header) + "." + b64(claims)
|
||||
return signing + "." + b64(ed25519.Sign(k.private, []byte(signing))), nil
|
||||
}
|
||||
|
||||
// ErrInvalid means a token is malformed, forged, for someone else, or expired.
|
||||
var ErrInvalid = errors.New("invalid token")
|
||||
|
||||
// Check verifies a token for audience and returns its claims.
|
||||
func (k *Keys) Check(token, audience string) (*Claims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
headerJSON, err1 := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
claimsJSON, err2 := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
sig, err3 := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
var header struct{ Alg, Kid string }
|
||||
if json.Unmarshal(headerJSON, &header) != nil || header.Alg != "EdDSA" || header.Kid != k.id {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
if !ed25519.Verify(k.private.Public().(ed25519.PublicKey), []byte(parts[0]+"."+parts[1]), sig) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
var c Claims
|
||||
if json.Unmarshal(claimsJSON, &c) != nil || c.Issuer != k.issuer || c.Audience != audience || time.Now().Unix() >= c.Expires {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// JWKS is the public key set, as JSON (RFC 8037 OKP keys).
|
||||
func (k *Keys) JWKS() []byte {
|
||||
pub := k.private.Public().(ed25519.PublicKey)
|
||||
b, _ := json.Marshal(map[string]any{"keys": []map[string]string{{
|
||||
"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", "kid": k.id, "x": b64(pub),
|
||||
}}})
|
||||
return b
|
||||
}
|
||||
|
||||
// Random is a random URL-safe string of n bytes' entropy, for session and refresh tokens.
|
||||
func Random(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b64(b)
|
||||
}
|
||||
Reference in new issue
Block a user