This repository has been archived on 2025-08-23. You can view files and clone it, but cannot push or open issues or pull requests.
Files
GreenCoast/internal/api/http.go
Dani 720c7e0b52 Updated the README
Added new security layers
2025-08-22 12:39:51 -04:00

847 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"math/big"
"mime"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"greencoast/internal/auth"
"greencoast/internal/index"
)
// BlobStore minimal interface for storage backends.
type BlobStore interface {
Put(hash string, r io.Reader) error
Get(hash string) (io.ReadCloser, int64, error)
Delete(hash string) error
}
type blobWalker interface {
Walk(func(hash string, size int64, mod time.Time) error) error
}
type DiscordProvider struct {
Enabled bool
ClientID string
ClientSecret string
RedirectURI string
}
type AuthProviders struct {
SigningSecretHex string
Discord DiscordProvider
}
type Server struct {
mux *http.ServeMux
store BlobStore
idx *index.Index
coarseTS bool
zeroTrust bool
signingKey []byte
// dev/testing flags
allowUnauth bool
devBearer string
// require proof-of-possession on every authd call
requirePoP bool
// SSE in-process
sseMu sync.Mutex
sseSubs map[chan []byte]struct{}
sseClosed bool
// SSO state + PKCE verifier + device key binding
stateMu sync.Mutex
states map[string]stateItem
// Nonce challenges for key-based login
nonceMu sync.Mutex
nonceExpiry map[string]time.Time
// PoP replay cache
replayMu sync.Mutex
replays map[string]time.Time
}
type stateItem struct {
Exp time.Time
Verifier string // PKCE code_verifier
DeviceKey string // "p256:<b64raw>" or "ed25519:<b64raw>"
ReturnNext string // optional
}
func New(store BlobStore, idx *index.Index, coarseTS bool, zeroTrust bool, providers AuthProviders) *Server {
key, _ := hex.DecodeString(strings.TrimSpace(providers.SigningSecretHex))
s := &Server{
mux: http.NewServeMux(),
store: store,
idx: idx,
coarseTS: coarseTS,
zeroTrust: zeroTrust,
signingKey: key,
allowUnauth: os.Getenv("GC_DEV_ALLOW_UNAUTH") == "true",
devBearer: os.Getenv("GC_DEV_BEARER"),
requirePoP: strings.ToLower(os.Getenv("GC_REQUIRE_POP")) != "false", // default true
sseSubs: make(map[chan []byte]struct{}),
states: make(map[string]stateItem),
nonceExpiry: make(map[string]time.Time),
replays: make(map[string]time.Time),
}
_ = mime.AddExtensionType(".js", "application/javascript; charset=utf-8")
_ = mime.AddExtensionType(".css", "text/css; charset=utf-8")
_ = mime.AddExtensionType(".html", "text/html; charset=utf-8")
_ = mime.AddExtensionType(".map", "application/json; charset=utf-8")
// Core
s.mux.HandleFunc("/healthz", s.handleHealthz)
// Auth (public-key)
s.mux.Handle("/v1/auth/key/challenge", s.withCORS(http.HandlerFunc(s.handleKeyChallenge)))
s.mux.Handle("/v1/auth/key/verify", s.withCORS(http.HandlerFunc(s.handleKeyVerify)))
// Discord SSO
s.mux.Handle("/v1/auth/discord/start", s.withCORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.handleDiscordStart(w, r, providers.Discord)
})))
s.mux.Handle("/v1/auth/discord/callback", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.handleDiscordCallback(w, r, providers.Discord)
}))
// Objects
s.mux.Handle("/v1/object", s.withCORS(http.HandlerFunc(s.handlePutObject)))
s.mux.Handle("/v1/object/", s.withCORS(http.HandlerFunc(s.handleObjectByHash)))
// Index + SSE
s.mux.Handle("/v1/index", s.withCORS(http.HandlerFunc(s.handleIndex)))
s.mux.Handle("/v1/index/stream", s.withCORS(http.HandlerFunc(s.handleIndexSSE)))
// GDPR/policy
s.mux.Handle("/v1/gdpr/policy", s.withCORS(http.HandlerFunc(s.handleGDPRPolicy)))
// Admin: reindex
s.mux.Handle("/v1/admin/reindex", s.withCORS(http.HandlerFunc(s.handleAdminReindex)))
return s
}
func (s *Server) ListenHTTP(addr string) error {
log.Printf("http listening on %s", addr)
server := &http.Server{
Addr: addr,
Handler: s.withCORS(s.mux),
ReadHeaderTimeout: 5 * time.Second,
}
return server.ListenAndServe()
}
func (s *Server) ListenHTTPS(addr, certFile, keyFile string) error {
log.Printf("https listening on %s", addr)
server := &http.Server{
Addr: addr,
Handler: s.withCORS(s.mux),
ReadHeaderTimeout: 5 * time.Second,
}
return server.ListenAndServeTLS(certFile, keyFile)
}
func (s *Server) secureHeaders(w http.ResponseWriter) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Resource-Policy", "same-site")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload")
}
func (s *Server) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.secureHeaders(w)
// Strong CSP for static will be set in static server; API allows connect from client origin
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS, POST")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GC-Private, X-GC-3P-Assent, X-GC-TZ, X-GC-Key, X-GC-TS, X-GC-Proof")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// ---------- Health & policy ----------
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
s.secureHeaders(w)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
io.WriteString(w, "ok")
}
func (s *Server) handleGDPRPolicy(w http.ResponseWriter, r *http.Request) {
s.secureHeaders(w)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
type policy struct {
StoresPII bool `json:"stores_pii"`
CollectIP bool `json:"collect_ip"`
CollectUA bool `json:"collect_user_agent"`
Timestamps string `json:"timestamps"`
ZeroTrust bool `json:"zero_trust"`
Accounts string `json:"accounts"`
ProofOfPoss bool `json:"proof_of_possession"`
}
resp := policy{
StoresPII: false,
CollectIP: false,
CollectUA: false,
Timestamps: map[bool]string{true: "coarse_utc", false: "utc"}[s.coarseTS],
ZeroTrust: s.zeroTrust,
Accounts: "public-key only",
ProofOfPoss: s.requirePoP,
}
_ = json.NewEncoder(w).Encode(resp)
}
// ---------- Auth helpers ----------
type authCtx struct {
sub string
cnf string // "p256:<b64raw>" or "ed25519:<b64raw>"
}
func (s *Server) parseAuth(w http.ResponseWriter, r *http.Request) (*authCtx, bool) {
// Dev bypass
if s.allowUnauth {
return &authCtx{sub: "dev"}, true
}
// Dev bearer
if s.devBearer != "" && r.Header.Get("Authorization") == "Bearer "+s.devBearer {
return &authCtx{sub: "dev"}, true
}
h := r.Header.Get("Authorization")
if h == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, false
}
// gc2 HMAC token
if strings.HasPrefix(h, "Bearer gc2.") && len(s.signingKey) != 0 {
claims, err := auth.VerifyGC2(s.signingKey, strings.TrimPrefix(h, "Bearer "), time.Now())
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, false
}
return &authCtx{sub: claims.Sub, cnf: claims.CNF}, true
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, false
}
func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac *authCtx, body []byte) bool {
if !s.requirePoP {
return true
}
pubHdr := r.Header.Get("X-GC-Key")
ts := r.Header.Get("X-GC-TS")
proof := r.Header.Get("X-GC-Proof")
if pubHdr == "" || ts == "" || proof == "" {
http.Error(w, "missing proof", http.StatusUnauthorized)
return false
}
// timestamp window
sec, _ := strconv.ParseInt(ts, 10, 64)
d := time.Since(time.Unix(sec, 0))
if d < -5*time.Minute || d > 5*time.Minute {
http.Error(w, "stale proof", http.StatusUnauthorized)
return false
}
// cnf must match
if ac.cnf == "" || ac.cnf != pubHdr {
http.Error(w, "key mismatch", http.StatusUnauthorized)
return false
}
// build message
sum := sha256.Sum256(body)
msg := strings.ToUpper(r.Method) + "\n" + r.URL.String() + "\n" + ts + "\n" + hex.EncodeToString(sum[:])
// verify signature
ok := false
switch {
case strings.HasPrefix(pubHdr, "ed25519:"):
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(pubHdr, "ed25519:"))
if err == nil {
sig, err := base64.RawURLEncoding.DecodeString(proof)
if err == nil && len(raw) == ed25519.PublicKeySize {
ok = ed25519.Verify(ed25519.PublicKey(raw), []byte(msg), sig)
}
}
case strings.HasPrefix(pubHdr, "p256:"):
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(pubHdr, "p256:"))
if err == nil && len(raw) == 65 && raw[0] == 0x04 {
x := new(big.Int).SetBytes(raw[1:33])
y := new(big.Int).SetBytes(raw[33:65])
pk := ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
der, err := base64.RawURLEncoding.DecodeString(proof)
if err == nil {
ok = ecdsa.VerifyASN1(&pk, []byte(msg), der)
}
}
}
if !ok {
http.Error(w, "bad proof", http.StatusUnauthorized)
return false
}
// replay cache
h := sha256.Sum256([]byte(proof + "|" + ts))
key := base64.RawURLEncoding.EncodeToString(h[:])
s.replayMu.Lock()
defer s.replayMu.Unlock()
if exp, exists := s.replays[key]; exists && time.Now().Before(exp) {
http.Error(w, "replay", http.StatusUnauthorized)
return false
}
s.replays[key] = time.Now().Add(10 * time.Minute)
return true
}
// ---------- Public-key auth: challenge/verify ----------
func (s *Server) handleKeyChallenge(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
nonce := s.randToken(16)
exp := time.Now().Add(10 * time.Minute)
s.nonceMu.Lock()
s.nonceExpiry[nonce] = exp
s.nonceMu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"nonce": nonce, "exp": exp.Unix()})
}
type keyVerifyReq struct {
Nonce string `json:"nonce"`
Alg string `json:"alg"` // "p256" or "ed25519"
Pub string `json:"pub"` // base64(raw) for that alg (p256 uncompressed point 65B; ed25519 32B)
Sig string `json:"sig"` // base64(signature over "key-verify\n"+nonce)
}
func (s *Server) handleKeyVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req keyVerifyReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Nonce == "" || req.Alg == "" || req.Pub == "" || req.Sig == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// check nonce
s.nonceMu.Lock()
exp, ok := s.nonceExpiry[req.Nonce]
if ok {
delete(s.nonceExpiry, req.Nonce)
}
s.nonceMu.Unlock()
if !ok || time.Now().After(exp) {
http.Error(w, "nonce invalid", http.StatusUnauthorized)
return
}
msg := "key-verify\n" + req.Nonce
pubRaw, err := base64.RawURLEncoding.DecodeString(req.Pub)
if err != nil {
http.Error(w, "bad pub", http.StatusBadRequest)
return
}
sigRaw, err := base64.RawURLEncoding.DecodeString(req.Sig)
if err != nil {
http.Error(w, "bad sig", http.StatusBadRequest)
return
}
var cnf string
switch strings.ToLower(req.Alg) {
case "ed25519":
if len(pubRaw) != ed25519.PublicKeySize || len(sigRaw) != ed25519.SignatureSize {
http.Error(w, "bad key", http.StatusBadRequest)
return
}
if !ed25519.Verify(ed25519.PublicKey(pubRaw), []byte(msg), sigRaw) {
http.Error(w, "verify failed", http.StatusUnauthorized)
return
}
cnf = "ed25519:" + req.Pub
case "p256":
if len(pubRaw) != 65 || pubRaw[0] != 0x04 {
http.Error(w, "bad key", http.StatusBadRequest)
return
}
x := new(big.Int).SetBytes(pubRaw[1:33])
y := new(big.Int).SetBytes(pubRaw[33:65])
pk := ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
// sigRaw assumed DER (WebCrypto)
if !ecdsa.VerifyASN1(&pk, []byte(msg), sigRaw) {
http.Error(w, "verify failed", http.StatusUnauthorized)
return
}
cnf = "p256:" + req.Pub
default:
http.Error(w, "unsupported alg", http.StatusBadRequest)
return
}
sub := auth.AccountIDFromPub(pubRaw)
ttl := 8 * time.Hour
now := time.Now()
bearer, err := auth.MintGC2(s.signingKey, auth.Claims{
Sub: sub, Exp: now.Add(ttl).Unix(), Nbf: now.Add(-60 * time.Second).Unix(),
Iss: "greencoast", Aud: "api", CNF: cnf,
})
if err != nil {
http.Error(w, "sign error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"bearer": bearer,
"sub": sub,
"exp": now.Add(ttl).Unix(),
})
}
// ---------- Objects & Index ----------
func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Limit body to 10 MiB by default
const maxBlob = int64(10 << 20)
r.Body = http.MaxBytesReader(w, r.Body, maxBlob)
// Read body first to support PoP over body hash
var buf bytes.Buffer
n, err := io.Copy(&buf, r.Body)
if err != nil {
http.Error(w, "read error", 500)
return
}
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
if !s.verifyPoP(w, r, ac, buf.Bytes()) {
return
}
isPrivate := r.Header.Get("X-GC-Private") == "1"
creatorTZ := strings.TrimSpace(r.Header.Get("X-GC-TZ"))
if creatorTZ != "" && !isReasonableTZ(creatorTZ) {
creatorTZ = ""
}
sum := sha256.Sum256(buf.Bytes())
hash := hex.EncodeToString(sum[:])
if err := s.store.Put(hash, bytes.NewReader(buf.Bytes())); err != nil {
http.Error(w, "store error", 500)
return
}
when := time.Now().UTC()
if s.coarseTS {
when = when.Truncate(time.Minute)
}
entry := index.Entry{
Hash: hash,
Bytes: n,
StoredAt: when.Format(time.RFC3339Nano),
Private: isPrivate,
CreatorTZ: creatorTZ,
}
if err := s.idx.Put(entry); err != nil {
http.Error(w, "index error", 500)
return
}
s.sseBroadcast(map[string]any{"event": "put", "data": entry})
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(entry)
}
func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/object/"), "/")
if len(parts) == 0 || parts[0] == "" {
http.NotFound(w, r)
return
}
hash := parts[0]
switch r.Method {
case http.MethodGet:
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
if !s.verifyPoP(w, r, ac, nil) {
return
}
rc, n, err := s.store.Get(hash)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer rc.Close()
w.Header().Set("Content-Type", "application/octet-stream")
if n > 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", n))
}
_, _ = io.Copy(w, rc)
case http.MethodDelete:
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
if !s.verifyPoP(w, r, ac, nil) {
return
}
if err := s.store.Delete(hash); err != nil {
http.Error(w, "delete error", 500)
return
}
_ = s.idx.Delete(hash)
s.sseBroadcast(map[string]any{"event": "delete", "data": map[string]string{"hash": hash}})
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
if !s.verifyPoP(w, r, ac, nil) {
return
}
items, err := s.idx.List()
if err != nil {
http.Error(w, "index error", 500)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(items)
}
func (s *Server) handleIndexSSE(w http.ResponseWriter, r *http.Request) {
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
if !s.verifyPoP(w, r, ac, nil) {
return
}
flusher, ok2 := w.(http.Flusher)
if !ok2 {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
ch := make(chan []byte, 8)
s.sseMu.Lock()
if s.sseClosed {
s.sseMu.Unlock()
http.Error(w, "closed", http.StatusGone)
return
}
s.sseSubs[ch] = struct{}{}
s.sseMu.Unlock()
fmt.Fprintf(w, "data: %s\n\n", `{"event":"hello","data":"ok"}`)
flusher.Flush()
ctx := r.Context()
t := time.NewTicker(25 * time.Second)
defer t.Stop()
defer func() {
s.sseMu.Lock()
delete(s.sseSubs, ch)
s.sseMu.Unlock()
close(ch)
}()
for {
select {
case <-ctx.Done():
return
case b := <-ch:
w.Write(b)
w.Write([]byte("\n\n"))
flusher.Flush()
case <-t.C:
w.Write([]byte("data: {}\n\n"))
flusher.Flush()
}
}
}
func (s *Server) sseBroadcast(v interface{}) {
b, _ := json.Marshal(v)
s.sseMu.Lock()
for ch := range s.sseSubs {
select {
case ch <- append([]byte("data: "), b...):
default:
}
}
s.sseMu.Unlock()
}
// ---------- Admin: reindex ----------
func (s *Server) handleAdminReindex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ac, ok := s.parseAuth(w, r)
if !ok {
return
}
if !s.verifyPoP(w, r, ac, nil) {
return
}
walker, ok2 := s.store.(blobWalker)
if !ok2 {
http.Error(w, "store does not support walk", http.StatusNotImplemented)
return
}
count := 0
err := walker.Walk(func(hash string, size int64, mod time.Time) error {
count++
return s.idx.Put(index.Entry{
Hash: hash,
Bytes: size,
StoredAt: mod.UTC().Format(time.RFC3339Nano),
Private: false,
})
})
if err != nil {
http.Error(w, "walk error: "+err.Error(), 500)
return
}
items, _ := s.idx.List()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"walked": count,
"indexed": len(items),
})
}
// ---------- Discord SSO with PKCE + device key binding ----------
func (s *Server) handleDiscordStart(w http.ResponseWriter, r *http.Request, cfg DiscordProvider) {
if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURI == "" {
http.Error(w, "discord sso disabled", http.StatusBadRequest)
return
}
if r.Header.Get("X-GC-3P-Assent") != "1" {
http.Error(w, "third-party provider not assented", http.StatusForbidden)
return
}
deviceKey := strings.TrimSpace(r.Header.Get("X-GC-Key"))
if deviceKey == "" {
http.Error(w, "device key required", http.StatusBadRequest)
return
}
// PKCE
verifier := s.randToken(32)
chalSum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(chalSum[:])
state := s.randToken(16)
s.stateMu.Lock()
s.states[state] = stateItem{
Exp: time.Now().Add(10 * time.Minute),
Verifier: verifier,
DeviceKey: deviceKey,
}
s.stateMu.Unlock()
v := url.Values{}
v.Set("response_type", "code")
v.Set("client_id", cfg.ClientID)
v.Set("redirect_uri", cfg.RedirectURI)
v.Set("scope", "identify")
v.Set("state", state)
v.Set("code_challenge", challenge)
v.Set("code_challenge_method", "S256")
authURL := (&url.URL{
Scheme: "https",
Host: "discord.com",
Path: "/api/oauth2/authorize",
RawQuery: v.Encode(),
}).String()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]string{"url": authURL})
}
func (s *Server) handleDiscordCallback(w http.ResponseWriter, r *http.Request, cfg DiscordProvider) {
if !cfg.Enabled {
http.Error(w, "disabled", http.StatusBadRequest)
return
}
q := r.URL.Query()
code := q.Get("code")
state := q.Get("state")
if code == "" || state == "" {
http.Error(w, "invalid state/code", http.StatusBadRequest)
return
}
s.stateMu.Lock()
item, ok := s.states[state]
if ok && time.Now().Before(item.Exp) {
delete(s.states, state)
}
s.stateMu.Unlock()
if !ok {
http.Error(w, "state expired", http.StatusBadRequest)
return
}
// Exchange code for token (with verifier)
form := url.Values{}
form.Set("client_id", cfg.ClientID)
form.Set("client_secret", cfg.ClientSecret)
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", cfg.RedirectURI)
form.Set("code_verifier", item.Verifier)
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://discord.com/api/oauth2/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "token exchange failed", 502)
return
}
defer res.Body.Close()
if res.StatusCode/100 != 2 {
b, _ := io.ReadAll(res.Body)
http.Error(w, "discord token error: "+string(b), 502)
return
}
var tok struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
if err := json.NewDecoder(res.Body).Decode(&tok); err != nil {
http.Error(w, "token decode failed", 502)
return
}
// Fetch user id
ureq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://discord.com/api/users/@me", nil)
ureq.Header.Set("Authorization", tok.TokenType+" "+tok.AccessToken)
ures, err := http.DefaultClient.Do(ureq)
if err != nil {
http.Error(w, "user fetch failed", 502)
return
}
defer ures.Body.Close()
if ures.StatusCode/100 != 2 {
b, _ := io.ReadAll(ures.Body)
http.Error(w, "discord user error: "+string(b), 502)
return
}
var user struct {
ID string `json:"id"`
}
if err := json.NewDecoder(ures.Body).Decode(&user); err != nil {
http.Error(w, "user decode failed", 502)
return
}
// Bind token to device key from /start
ttl := 8 * time.Hour
now := time.Now()
sub := "discord:" + user.ID
bearer, err := auth.MintGC2(s.signingKey, auth.Claims{
Sub: sub, Exp: now.Add(ttl).Unix(), Nbf: now.Add(-60 * time.Second).Unix(),
Iss: "greencoast", Aud: "api", CNF: item.DeviceKey,
})
if err != nil {
http.Error(w, "sign error", 500)
return
}
u, _ := url.Parse(cfg.RedirectURI)
u.Fragment = "bearer=" + url.QueryEscape(bearer) + "&next=/"
http.Redirect(w, r, u.String(), http.StatusFound)
}
// ---------- Utilities, shutdown ----------
func isReasonableTZ(tz string) bool {
if !strings.Contains(tz, "/") || len(tz) > 64 {
return false
}
for _, r := range tz {
if !(r == '/' || r == '_' || r == '-' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
return false
}
}
return true
}
func (s *Server) Shutdown(ctx context.Context) error {
s.sseMu.Lock()
s.sseClosed = true
for ch := range s.sseSubs {
close(ch)
}
s.sseSubs = make(map[chan []byte]struct{})
s.sseMu.Unlock()
return nil
}
func (s *Server) randToken(n int) string {
// HMAC over time + counter to avoid importing crypto/rand; good enough for state/nonce
// (If you prefer, switch to crypto/rand.)
b := []byte(fmt.Sprintf("%d|%d", time.Now().UnixNano(), len(s.states)+len(s.nonceExpiry)))
m := hmac.New(sha256.New, []byte(fmt.Sprintf("%p", s)))
m.Write(b)
sum := m.Sum(nil)
return base64.RawURLEncoding.EncodeToString(sum[:n])
}