74 lines
2.3 KiB
Go
74 lines
2.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DeriveOAuthStateSecret produces a stable per-server secret for signing
|
|
// OAuth `state` payloads. Derived from the JWT secret so we don't add a
|
|
// new required config knob; rotating JWT_SECRET invalidates in-flight
|
|
// OAuth flows, which is acceptable (state TTL is 10 minutes).
|
|
func DeriveOAuthStateSecret(jwtSecret []byte) []byte {
|
|
h := hmac.New(sha256.New, jwtSecret)
|
|
h.Write([]byte("silo/oauth-state-v1"))
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
// StatePayload is what we sign into the OAuth `state` parameter.
|
|
// The payload round-trips through the upstream IdP (WHMCS, Authentik, etc.)
|
|
// as the literal `state` query parameter — verification on /callback both
|
|
// authenticates the request as ours and recovers install_id + expiry.
|
|
type StatePayload struct {
|
|
Nonce string `json:"n"`
|
|
InstallID string `json:"i"`
|
|
ExpiresAt time.Time `json:"e"`
|
|
}
|
|
|
|
// SignState returns a self-contained signed state of the form
|
|
// "<base64url(json(payload))>.<base64url(hmac_sha256)>".
|
|
func SignState(secret []byte, p StatePayload) string {
|
|
body, _ := json.Marshal(p)
|
|
bodyB64 := base64.RawURLEncoding.EncodeToString(body)
|
|
mac := hmac.New(sha256.New, secret)
|
|
mac.Write([]byte(bodyB64))
|
|
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
return bodyB64 + "." + sig
|
|
}
|
|
|
|
// VerifyState parses, verifies the HMAC, checks expiry, and returns the payload.
|
|
func VerifyState(secret []byte, signed string) (StatePayload, error) {
|
|
parts := strings.SplitN(signed, ".", 2)
|
|
if len(parts) != 2 {
|
|
return StatePayload{}, errors.New("malformed state")
|
|
}
|
|
bodyB64, sigB64 := parts[0], parts[1]
|
|
|
|
expectedMac := hmac.New(sha256.New, secret)
|
|
expectedMac.Write([]byte(bodyB64))
|
|
expected := base64.RawURLEncoding.EncodeToString(expectedMac.Sum(nil))
|
|
|
|
if !hmac.Equal([]byte(sigB64), []byte(expected)) {
|
|
return StatePayload{}, errors.New("state signature mismatch")
|
|
}
|
|
|
|
body, err := base64.RawURLEncoding.DecodeString(bodyB64)
|
|
if err != nil {
|
|
return StatePayload{}, fmt.Errorf("decode state body: %w", err)
|
|
}
|
|
var p StatePayload
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
return StatePayload{}, fmt.Errorf("unmarshal state body: %w", err)
|
|
}
|
|
if time.Now().After(p.ExpiresAt) {
|
|
return StatePayload{}, errors.New("state expired")
|
|
}
|
|
return p, nil
|
|
}
|