feat: oauth proxy for mal/anilist auth

This commit is contained in:
edde746
2026-04-24 09:46:45 +02:00
parent 1582fdbd5b
commit e8729a4e27
49 changed files with 1873 additions and 349 deletions
+2 -1
View File
@@ -2,10 +2,11 @@ FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY main.go .
COPY *.go .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /relay .
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /relay /relay
VOLUME /data
EXPOSE 8080
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REMOTE="root@212.132.75.249"
SSH_KEY="$HOME/.ssh/id_edde"
REMOTE_DIR="/opt/plezy-relay"
SSH="ssh -i $SSH_KEY $REMOTE"
cd "$SCRIPT_DIR"
echo "Syncing files to $REMOTE:$REMOTE_DIR..."
rsync -avz --exclude='plezy-relay' --exclude='plezy-server-linux-amd64' \
-e "ssh -i $SSH_KEY" \
. "$REMOTE:$REMOTE_DIR/"
echo "Deploying..."
$SSH "cd $REMOTE_DIR && docker compose up -d --build"
echo "Done."
+5
View File
@@ -8,6 +8,11 @@ services:
- logs:/data
expose:
- "8080"
environment:
OAUTH_BASE_URL: https://ice.plezy.app
MAL_CLIENT_ID: ${MAL_CLIENT_ID:-}
ANILIST_CLIENT_ID: ${ANILIST_CLIENT_ID:-}
ANILIST_CLIENT_SECRET: ${ANILIST_CLIENT_SECRET:-}
logging:
driver: "json-file"
options:
+17
View File
@@ -97,6 +97,14 @@ func (rl *rateLimiter) allow() bool {
return true
}
// stale reports whether a limiter hasn't been touched in over 10 minutes —
// safe to GC from a per-IP map.
func (rl *rateLimiter) stale(now time.Time) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
return now.Sub(rl.lastTime) > 10*time.Minute
}
// --- Connection tracker (per-IP limits) ---
type connTracker struct {
@@ -547,11 +555,16 @@ type Server struct {
logs *logStore
conns *connTracker
snap *snapshotter
oauth *oauthProxy // nil when OAUTH_BASE_URL is unset
mu sync.RWMutex
}
func newServer(logDir, stateFile string) *Server {
s := &Server{rooms: make(map[string]*Room), logs: newLogStore(logDir), conns: newConnTracker()}
if p, ok := oauthConfigFromEnv(); ok {
s.oauth = p
log.Printf("oauth: proxy enabled (base=%s, services=%d)", p.baseURL, len(p.services))
}
s.snap = newSnapshotter(stateFile, s.buildSnapshot)
if err := s.loadSnapshot(stateFile); err != nil {
log.Printf("snapshot: load error: %v", err)
@@ -669,6 +682,9 @@ func (s *Server) runCleanupStep(now time.Time) {
}
s.logs.cleanup()
s.conns.cleanup()
if s.oauth != nil {
s.oauth.cleanup()
}
s.conns.mu.Lock()
log.Printf("stats: conns=%d ips=%d rooms=%d",
@@ -996,6 +1012,7 @@ func main() {
})
mux.HandleFunc("/logs", srv.handlePostLogs)
mux.HandleFunc("/logs/", srv.handleGetLogs)
registerOAuthRoutes(mux, srv.oauth)
httpSrv := &http.Server{Addr: *addr, Handler: mux}
+492
View File
@@ -0,0 +1,492 @@
package main
// OAuth proxy: relays MAL + AniList authorization-code flows for devices that
// can't listen on localhost (TVs) or lack a browser (headless set-top boxes
// pair via a phone QR scan). Sessions live in memory for 10 minutes; AniList's
// client secret lives only in env vars. Access tokens transit the server
// briefly during code→token exchange and are never logged or persisted.
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
const (
oauthSessionTTL = 10 * time.Minute
oauthResultWait = 50 * time.Second
oauthMaxSessions = 5000
oauthStartBurst = 3
oauthStartRateSustained = 1
oauthSessionIDBytes = 18 // 144 bits → 24 base64url chars
oauthPKCEVerifierLen = 64
oauthUpstreamTimeout = 15 * time.Second
)
// oauthServiceConfig describes a single upstream OAuth provider. Populated from
// env vars in oauthConfigFromEnv. A service with an empty ClientID is disabled.
type oauthServiceConfig struct {
ClientID string
ClientSecret string // empty ⇒ provider doesn't issue/require one (MAL w/ PKCE)
AuthorizeURL string
TokenURL string
Scopes string
UsePKCE bool
PKCEMethod string // "plain" or "S256"
}
type oauthTokenResult struct {
AccessToken string `json:"accessToken,omitempty"`
RefreshToken string `json:"refreshToken,omitempty"`
ExpiresIn int `json:"expiresIn,omitempty"`
Error string `json:"error,omitempty"`
}
// oauthSession is created by /auth/start and lives until it's consumed by a
// successful /auth/result (which deletes the map entry) or GC'd after
// oauthSessionTTL. The `done` channel is closed exactly once (by complete) and
// unblocks waiters.
type oauthSession struct {
id string
service string
codeVerifier string // MAL PKCE; empty for AniList. Cleared after token exchange.
createdAt time.Time
done chan struct{}
mu sync.Mutex
result *oauthTokenResult
}
func (s *oauthSession) complete(r oauthTokenResult) {
s.mu.Lock()
defer s.mu.Unlock()
if s.result != nil {
return
}
s.result = &r
s.codeVerifier = "" // Secret, not needed after exchange.
close(s.done)
}
// wait blocks until the session is completed or ctx is cancelled.
func (s *oauthSession) wait(ctx context.Context) (*oauthTokenResult, error) {
select {
case <-s.done:
s.mu.Lock()
defer s.mu.Unlock()
return s.result, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
type oauthProxy struct {
baseURL string // e.g. https://ice.plezy.app
services map[string]oauthServiceConfig
client *http.Client
mu sync.Mutex
sessions map[string]*oauthSession
ipMu sync.Mutex
ipRate map[string]*rateLimiter
}
func newOAuthProxy(baseURL string, services map[string]oauthServiceConfig) *oauthProxy {
return &oauthProxy{
baseURL: strings.TrimRight(baseURL, "/"),
services: services,
client: &http.Client{Timeout: oauthUpstreamTimeout},
sessions: make(map[string]*oauthSession),
ipRate: make(map[string]*rateLimiter),
}
}
// oauthConfigFromEnv reads the public base URL and per-service creds from the
// environment. Returns (nil, false) if OAUTH_BASE_URL is unset — the caller
// wires this as "OAuth disabled, endpoints return 503".
func oauthConfigFromEnv() (*oauthProxy, bool) {
base := os.Getenv("OAUTH_BASE_URL")
if base == "" {
return nil, false
}
services := map[string]oauthServiceConfig{}
if id := os.Getenv("MAL_CLIENT_ID"); id != "" {
services["mal"] = oauthServiceConfig{
ClientID: id,
AuthorizeURL: "https://myanimelist.net/v1/oauth2/authorize",
TokenURL: "https://myanimelist.net/v1/oauth2/token",
UsePKCE: true,
PKCEMethod: "plain", // MAL rejects S256 despite RFC 7636
}
}
if id := os.Getenv("ANILIST_CLIENT_ID"); id != "" {
services["anilist"] = oauthServiceConfig{
ClientID: id,
ClientSecret: os.Getenv("ANILIST_CLIENT_SECRET"),
AuthorizeURL: "https://anilist.co/api/v2/oauth/authorize",
TokenURL: "https://anilist.co/api/v2/oauth/token",
}
}
return newOAuthProxy(base, services), true
}
// registerOAuthRoutes registers all /auth/* handlers. If p is nil (no env
// config), all paths 503 so the integration page clearly says "not configured".
func registerOAuthRoutes(mux *http.ServeMux, p *oauthProxy) {
if p == nil {
mux.HandleFunc("/auth/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "OAuth proxy not configured", http.StatusServiceUnavailable)
})
return
}
mux.HandleFunc("/auth/start", p.handleStart)
mux.HandleFunc("/auth/result", p.handleResult)
mux.HandleFunc("/auth/done", p.handleDone)
mux.HandleFunc("/auth/", p.handleAuthRoot)
}
// handleAuthRoot dispatches /auth/... paths that aren't served by their own
// registered handler. Covers /auth/:service and /auth/:service/callback.
func (p *oauthProxy) handleAuthRoot(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/auth/")
parts := strings.SplitN(rest, "/", 2)
if len(parts) == 0 || parts[0] == "" {
http.NotFound(w, r)
return
}
service := parts[0]
if len(parts) == 1 {
p.handleAuthorize(w, r, service)
return
}
if parts[1] == "callback" {
p.handleCallback(w, r, service)
return
}
http.NotFound(w, r)
}
// POST /auth/start body={"service":"mal"|"anilist"}
// Response: {"session":"...","url":"https://.../auth/:service?session=...","expiresIn":600}
func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ip := clientIP(r)
if !p.ipAllow(ip) {
http.Error(w, "Rate limited", http.StatusTooManyRequests)
return
}
var body struct {
Service string `json:"service"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 512)).Decode(&body); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
cfg, ok := p.services[body.Service]
if !ok {
http.Error(w, "Unknown service", http.StatusBadRequest)
return
}
// Generate tokens outside the map lock — crypto/rand syscalls would
// otherwise serialize concurrent /auth/start calls.
sess := &oauthSession{
id: randToken(oauthSessionIDBytes),
service: body.Service,
createdAt: time.Now(),
done: make(chan struct{}),
}
if cfg.UsePKCE {
sess.codeVerifier = randPKCEVerifier()
}
p.mu.Lock()
if len(p.sessions) >= oauthMaxSessions {
p.mu.Unlock()
http.Error(w, "Server busy", http.StatusServiceUnavailable)
return
}
p.sessions[sess.id] = sess
p.mu.Unlock()
resp := map[string]any{
"session": sess.id,
"url": fmt.Sprintf("%s/auth/%s?session=%s", p.baseURL, url.PathEscape(body.Service), url.QueryEscape(sess.id)),
"expiresIn": int(oauthSessionTTL.Seconds()),
}
writeJSON(w, http.StatusOK, resp)
}
// GET /auth/:service?session=X → 302 upstream authorize URL
func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, service string) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cfg, ok := p.services[service]
if !ok {
http.NotFound(w, r)
return
}
sessionID := r.URL.Query().Get("session")
p.mu.Lock()
sess := p.sessions[sessionID]
p.mu.Unlock()
if sess == nil || sess.service != service {
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
return
}
q := url.Values{
"response_type": {"code"},
"client_id": {cfg.ClientID},
"redirect_uri": {p.redirectURI(service)},
"state": {sess.id},
}
if cfg.Scopes != "" {
q.Set("scope", cfg.Scopes)
}
if cfg.UsePKCE {
q.Set("code_challenge", sess.codeVerifier) // plain method ⇒ challenge == verifier
q.Set("code_challenge_method", cfg.PKCEMethod)
}
http.Redirect(w, r, cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound)
}
// GET /auth/:service/callback?code=...&state=... → exchange, park, render page
func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, service string) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cfg, ok := p.services[service]
if !ok {
http.NotFound(w, r)
return
}
q := r.URL.Query()
state := q.Get("state")
p.mu.Lock()
sess := p.sessions[state]
p.mu.Unlock()
if sess == nil || sess.service != service {
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
return
}
if upstreamErr := q.Get("error"); upstreamErr != "" {
sess.complete(oauthTokenResult{Error: upstreamErr})
renderErrorPage(w, http.StatusOK, "Sign-in was cancelled.")
return
}
code := q.Get("code")
if code == "" {
sess.complete(oauthTokenResult{Error: "missing_code"})
renderErrorPage(w, http.StatusBadRequest, "Sign-in response was incomplete. Please try again.")
return
}
tok, err := p.exchangeCode(r.Context(), cfg, service, sess, code)
if err != nil {
log.Printf("oauth: %s token exchange failed: %v", service, err)
sess.complete(oauthTokenResult{Error: "exchange_failed"})
renderErrorPage(w, http.StatusBadGateway, "Couldn't complete sign-in. Please try again.")
return
}
sess.complete(tok)
renderSuccessPage(w)
}
// GET /auth/result?session=X → long-poll, returns tokens on success
func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
sessionID := r.URL.Query().Get("session")
p.mu.Lock()
sess := p.sessions[sessionID]
p.mu.Unlock()
if sess == nil {
http.Error(w, "Session not found", http.StatusGone)
return
}
ctx, cancel := context.WithTimeout(r.Context(), oauthResultWait)
defer cancel()
result, err := sess.wait(ctx)
if err != nil {
// Client should retry — session may still receive its callback.
w.WriteHeader(http.StatusNoContent)
return
}
// Session consumed — delete so a retry sees 410 instead of racing another wait.
p.mu.Lock()
delete(p.sessions, sess.id)
p.mu.Unlock()
if result.Error != "" {
writeJSON(w, http.StatusOK, map[string]any{"error": result.Error})
return
}
writeJSON(w, http.StatusOK, result)
}
// GET /auth/done — static success page (Simkl's redirect target).
func (p *oauthProxy) handleDone(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
renderSuccessPage(w)
}
func (p *oauthProxy) exchangeCode(ctx context.Context, cfg oauthServiceConfig, service string, sess *oauthSession, code string) (oauthTokenResult, error) {
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"client_id": {cfg.ClientID},
"redirect_uri": {p.redirectURI(service)},
}
if cfg.ClientSecret != "" {
form.Set("client_secret", cfg.ClientSecret)
}
if cfg.UsePKCE {
form.Set("code_verifier", sess.codeVerifier)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return oauthTokenResult{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return oauthTokenResult{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return oauthTokenResult{}, fmt.Errorf("upstream HTTP %d", resp.StatusCode)
}
var parsed struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&parsed); err != nil {
return oauthTokenResult{}, fmt.Errorf("decode: %w", err)
}
if parsed.AccessToken == "" {
return oauthTokenResult{}, errors.New("missing access_token in upstream response")
}
return oauthTokenResult{
AccessToken: parsed.AccessToken,
RefreshToken: parsed.RefreshToken,
ExpiresIn: parsed.ExpiresIn,
}, nil
}
func (p *oauthProxy) redirectURI(service string) string {
return fmt.Sprintf("%s/auth/%s/callback", p.baseURL, service)
}
// cleanup drops sessions past oauthSessionTTL. Called by the main cleanup loop.
func (p *oauthProxy) cleanup() {
now := time.Now()
p.mu.Lock()
for id, sess := range p.sessions {
if now.Sub(sess.createdAt) > oauthSessionTTL {
delete(p.sessions, id)
}
}
p.mu.Unlock()
p.ipMu.Lock()
for ip, rl := range p.ipRate {
if rl.stale(now) {
delete(p.ipRate, ip)
}
}
p.ipMu.Unlock()
}
func (p *oauthProxy) ipAllow(ip string) bool {
p.ipMu.Lock()
defer p.ipMu.Unlock()
rl, ok := p.ipRate[ip]
if !ok {
rl = newRateLimiter(oauthStartBurst, oauthStartRateSustained)
p.ipRate[ip] = rl
}
return rl.allow()
}
const successPageHTML = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Signed in</title><style>html,body{margin:0;height:100%}body{display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:-apple-system,system-ui,sans-serif;background:#fff;color:#1a1a1a;text-align:center;padding:1em;box-sizing:border-box}@media(prefers-color-scheme:dark){body{background:#0f0f0f;color:#f5f5f5}}.check{width:72px;height:72px;margin-bottom:20px}h2{margin:0 0 8px;font-weight:600;font-size:1.25rem}p{margin:0;opacity:.7;font-size:.95rem}</style><body><svg class="check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#22c55e"/><path d="M7 12.5l3 3 7-7" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg><h2>Signed in to Plezy</h2><p>You can close this tab and return to the app.</p></body>`
// Split around the message so CSS `%` literals don't collide with Fprintf verbs.
const errorPagePrefix = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign-in failed</title><style>html,body{margin:0;height:100%}body{display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:-apple-system,system-ui,sans-serif;background:#fff;color:#1a1a1a;text-align:center;padding:1em;box-sizing:border-box}@media(prefers-color-scheme:dark){body{background:#0f0f0f;color:#f5f5f5}}.x{width:72px;height:72px;margin-bottom:20px}h2{margin:0 0 8px;font-weight:600;font-size:1.25rem}p{margin:0;opacity:.7;font-size:.95rem}</style><body><svg class="x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ef4444"/><path d="M8 8l8 8M16 8l-8 8" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round"/></svg><h2>Sign-in failed</h2><p>`
const errorPageSuffix = `</p></body>`
func renderSuccessPage(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
io.WriteString(w, successPageHTML)
}
func renderErrorPage(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
io.WriteString(w, errorPagePrefix)
io.WriteString(w, html.EscapeString(message))
io.WriteString(w, errorPageSuffix)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func randToken(numBytes int) string {
b := make([]byte, numBytes)
if _, err := rand.Read(b); err != nil {
// crypto/rand failing is catastrophic; log.Fatalf matches the style
// in newLogStore for similar unrecoverable init failures.
log.Fatalf("crypto/rand: %v", err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
// randPKCEVerifier returns a 64-char string from MAL's required alphabet
// (RFC 7636 §4.1 unreserved set).
func randPKCEVerifier() string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
b := make([]byte, oauthPKCEVerifierLen)
if _, err := rand.Read(b); err != nil {
log.Fatalf("crypto/rand: %v", err)
}
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return string(b)
}
+551
View File
@@ -0,0 +1,551 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
)
// mockUpstream runs an httptest server that impersonates MAL/AniList. It
// records the last token-exchange form submission and returns a canned
// access_token/refresh_token response.
type mockUpstream struct {
srv *httptest.Server
mu sync.Mutex
lastForm url.Values
tokenReply string
tokenCode int
}
// httpGet / httpPost / httpDo wrap the stdlib calls to fail the test on error.
// Keeps test bodies one-liner without tripping `go vet`'s
// "using resp before checking errors" rule.
func httpGet(t *testing.T, url string) *http.Response {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
return resp
}
func httpPost(t *testing.T, url, contentType string, body io.Reader) *http.Response {
t.Helper()
resp, err := http.Post(url, contentType, body)
if err != nil {
t.Fatalf("POST %s: %v", url, err)
}
return resp
}
func httpDo(t *testing.T, req *http.Request) *http.Response {
t.Helper()
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do %s %s: %v", req.Method, req.URL, err)
}
return resp
}
func newMockUpstream(t *testing.T) *mockUpstream {
t.Helper()
m := &mockUpstream{
tokenReply: `{"access_token":"tok-abc","refresh_token":"ref-xyz","expires_in":2678400}`,
tokenCode: http.StatusOK,
}
m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth/authorize":
// Unused in tests — we assert on the 302 Location from our proxy.
w.WriteHeader(http.StatusOK)
case "/oauth/token":
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
m.mu.Lock()
m.lastForm = r.PostForm
code := m.tokenCode
reply := m.tokenReply
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
io.WriteString(w, reply)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(m.srv.Close)
return m
}
func (m *mockUpstream) setReply(code int, body string) {
m.mu.Lock()
defer m.mu.Unlock()
m.tokenCode = code
m.tokenReply = body
}
func (m *mockUpstream) form() url.Values {
m.mu.Lock()
defer m.mu.Unlock()
return m.lastForm
}
// newOAuthHarness boots a relay-less httptest server that mounts /auth/* only,
// with `mal` and `anilist` services pointed at a shared mock upstream.
type oauthHarness struct {
proxy *oauthProxy
srv *httptest.Server
base string
upstream *mockUpstream
}
func newOAuthHarness(t *testing.T) *oauthHarness {
t.Helper()
up := newMockUpstream(t)
proxy := newOAuthProxy("http://placeholder", map[string]oauthServiceConfig{
"mal": {
ClientID: "mal-id",
AuthorizeURL: up.srv.URL + "/oauth/authorize",
TokenURL: up.srv.URL + "/oauth/token",
UsePKCE: true,
PKCEMethod: "plain",
},
"anilist": {
ClientID: "anilist-id",
ClientSecret: "anilist-secret",
AuthorizeURL: up.srv.URL + "/oauth/authorize",
TokenURL: up.srv.URL + "/oauth/token",
},
})
mux := http.NewServeMux()
registerOAuthRoutes(mux, proxy)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
// Rewire baseURL to the real httptest URL so redirect_uri computes correctly.
proxy.baseURL = srv.URL
return &oauthHarness{proxy: proxy, srv: srv, base: srv.URL, upstream: up}
}
func (h *oauthHarness) startSession(t *testing.T, service, ip string) (sessionID, qrURL string) {
t.Helper()
body, _ := json.Marshal(map[string]string{"service": service})
req, _ := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if ip != "" {
req.Header.Set("X-Forwarded-For", ip)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("start: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("start status=%d", resp.StatusCode)
}
var out struct {
Session string `json:"session"`
URL string `json:"url"`
ExpiresIn int `json:"expiresIn"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.Session == "" || out.URL == "" {
t.Fatalf("empty session/url: %+v", out)
}
return out.Session, out.URL
}
// ====== /auth/start ======
func TestOAuthStartReturnsSessionAndURL(t *testing.T) {
h := newOAuthHarness(t)
session, qr := h.startSession(t, "mal", "1.2.3.4")
if !strings.HasPrefix(qr, h.base+"/auth/mal?session=") {
t.Fatalf("url=%q doesn't look like the authorize start URL", qr)
}
if !strings.Contains(qr, url.QueryEscape(session)) {
t.Fatalf("url=%q missing session token", qr)
}
}
func TestOAuthStartRejectsUnknownService(t *testing.T) {
h := newOAuthHarness(t)
body, _ := json.Marshal(map[string]string{"service": "nope"})
resp := httpPost(t, h.base+"/auth/start", "application/json", bytes.NewReader(body))
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status=%d want 400", resp.StatusCode)
}
}
func TestOAuthStartRejectsInvalidJSON(t *testing.T) {
h := newOAuthHarness(t)
resp := httpPost(t, h.base+"/auth/start", "application/json", strings.NewReader("not json"))
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status=%d want 400", resp.StatusCode)
}
}
func TestOAuthStartRateLimitedPerIP(t *testing.T) {
h := newOAuthHarness(t)
ip := "5.5.5.5"
for i := 0; i < oauthStartBurst; i++ {
h.startSession(t, "mal", ip) // should all succeed
}
body, _ := json.Marshal(map[string]string{"service": "mal"})
req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", ip)
resp := httpDo(t, req)
defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("status=%d want 429", resp.StatusCode)
}
}
func TestOAuthStartMethodNotAllowed(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/start")
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("status=%d want 405", resp.StatusCode)
}
}
// ====== /auth/:service (authorize redirect) ======
func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "1.1.1.1")
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(h.base + "/auth/mal?session=" + url.QueryEscape(sess))
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("status=%d want 302", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatalf("parse Location: %v", err)
}
q := loc.Query()
if q.Get("client_id") != "mal-id" {
t.Errorf("client_id=%q", q.Get("client_id"))
}
if q.Get("response_type") != "code" {
t.Errorf("response_type=%q", q.Get("response_type"))
}
if q.Get("state") != sess {
t.Errorf("state=%q, want session %q", q.Get("state"), sess)
}
if q.Get("code_challenge_method") != "plain" {
t.Errorf("code_challenge_method=%q, want plain", q.Get("code_challenge_method"))
}
if q.Get("code_challenge") == "" {
t.Error("code_challenge missing")
}
if !strings.HasSuffix(q.Get("redirect_uri"), "/auth/mal/callback") {
t.Errorf("redirect_uri=%q", q.Get("redirect_uri"))
}
}
func TestOAuthAuthorizeAnilistRedirectOmitsPKCE(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "anilist", "1.1.1.2")
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(h.base + "/auth/anilist?session=" + url.QueryEscape(sess))
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
loc, _ := url.Parse(resp.Header.Get("Location"))
q := loc.Query()
if q.Get("code_challenge") != "" {
t.Errorf("anilist redirect should not include code_challenge, got %q", q.Get("code_challenge"))
}
}
func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/mal?session=bogus")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d want 404", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "no longer valid") {
t.Errorf("expected error page html, got: %s", body)
}
}
func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "1.1.1.3")
// Try to use the MAL session against the AniList authorize endpoint.
resp := httpGet(t, h.base+"/auth/anilist?session="+url.QueryEscape(sess))
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d want 404", resp.StatusCode)
}
}
// ====== /auth/:service/callback + /auth/result ======
func TestOAuthCallbackExchangesCodeAndResultReturnsTokens(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "2.2.2.1")
resultCh := make(chan map[string]any, 1)
go func() {
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
if err != nil {
resultCh <- map[string]any{"_err": err.Error()}
return
}
defer resp.Body.Close()
var m map[string]any
_ = json.NewDecoder(resp.Body).Decode(&m)
resultCh <- m
}()
// Hit the callback as the upstream browser would.
cbURL := fmt.Sprintf("%s/auth/mal/callback?code=CODE123&state=%s", h.base, url.QueryEscape(sess))
resp, err := http.Get(cbURL)
if err != nil {
t.Fatalf("callback: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("callback status=%d", resp.StatusCode)
}
// Upstream should have been called with PKCE + code.
form := h.upstream.form()
if form.Get("code") != "CODE123" {
t.Errorf("upstream code=%q", form.Get("code"))
}
if form.Get("code_verifier") == "" {
t.Error("upstream missing code_verifier (PKCE)")
}
if form.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type=%q", form.Get("grant_type"))
}
select {
case got := <-resultCh:
if got["accessToken"] != "tok-abc" {
t.Errorf("accessToken=%v want tok-abc", got["accessToken"])
}
if got["refreshToken"] != "ref-xyz" {
t.Errorf("refreshToken=%v want ref-xyz", got["refreshToken"])
}
case <-time.After(3 * time.Second):
t.Fatal("result never returned")
}
}
func TestOAuthCallbackUpstreamError(t *testing.T) {
h := newOAuthHarness(t)
h.upstream.setReply(http.StatusBadRequest, `{"error":"invalid_grant"}`)
sess, _ := h.startSession(t, "mal", "2.2.2.2")
resultCh := make(chan map[string]any, 1)
go func() {
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
if err != nil {
resultCh <- map[string]any{"_err": err.Error()}
return
}
defer resp.Body.Close()
var m map[string]any
_ = json.NewDecoder(resp.Body).Decode(&m)
resultCh <- m
}()
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?code=CODE&state=%s", h.base, url.QueryEscape(sess)))
resp.Body.Close()
select {
case got := <-resultCh:
if got["error"] != "exchange_failed" {
t.Errorf("expected error=exchange_failed, got %v", got)
}
case <-time.After(3 * time.Second):
t.Fatal("result never returned")
}
}
func TestOAuthCallbackUserCancelled(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "2.2.2.3")
resultCh := make(chan map[string]any, 1)
go func() {
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
if err != nil {
resultCh <- map[string]any{"_err": err.Error()}
return
}
defer resp.Body.Close()
var m map[string]any
_ = json.NewDecoder(resp.Body).Decode(&m)
resultCh <- m
}()
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?error=access_denied&state=%s", h.base, url.QueryEscape(sess)))
resp.Body.Close()
select {
case got := <-resultCh:
if got["error"] != "access_denied" {
t.Errorf("expected error=access_denied, got %v", got)
}
case <-time.After(3 * time.Second):
t.Fatal("result never returned")
}
}
func TestOAuthCallbackUnknownSessionIgnored(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/mal/callback?code=X&state=bogus")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d want 404", resp.StatusCode)
}
}
func TestOAuthResultUnknownSession(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/result?session=nope")
defer resp.Body.Close()
if resp.StatusCode != http.StatusGone {
t.Fatalf("status=%d want 410", resp.StatusCode)
}
}
func TestOAuthResultConsumedSecondCallIsGone(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "3.3.3.1")
// Pre-seat the result so the first /auth/result returns immediately.
h.proxy.mu.Lock()
h.proxy.sessions[sess].complete(oauthTokenResult{AccessToken: "tok"})
h.proxy.mu.Unlock()
r1 := httpGet(t, h.base+"/auth/result?session="+url.QueryEscape(sess))
r1.Body.Close()
if r1.StatusCode != http.StatusOK {
t.Fatalf("first result status=%d", r1.StatusCode)
}
// After consumption the session is deleted; second call sees unknown session.
r2 := httpGet(t, h.base+"/auth/result?session="+url.QueryEscape(sess))
r2.Body.Close()
if r2.StatusCode != http.StatusGone {
t.Fatalf("second result status=%d want 410", r2.StatusCode)
}
}
// ====== Cleanup ======
func TestOAuthCleanupExpiresOldSessions(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "4.4.4.1")
h.proxy.mu.Lock()
h.proxy.sessions[sess].createdAt = time.Now().Add(-2 * oauthSessionTTL)
h.proxy.mu.Unlock()
h.proxy.cleanup()
h.proxy.mu.Lock()
_, exists := h.proxy.sessions[sess]
h.proxy.mu.Unlock()
if exists {
t.Fatal("expired session should have been cleaned up")
}
}
// ====== /auth/done ======
func TestOAuthDoneRendersSuccessPage(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/done")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "Signed in to Plezy") {
t.Errorf("body missing success message: %s", body)
}
}
// ====== Disabled proxy returns 503 ======
func TestOAuthRoutesReturn503WhenDisabled(t *testing.T) {
mux := http.NewServeMux()
registerOAuthRoutes(mux, nil)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
resp := httpGet(t, srv.URL+"/auth/start")
resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("status=%d want 503", resp.StatusCode)
}
}
// ====== Path dispatch ======
func TestOAuthAuthRootRejectsBadPaths(t *testing.T) {
h := newOAuthHarness(t)
for _, path := range []string{"/auth/mal/weird", "/auth/unknown", "/auth/mal/callback/extra"} {
resp := httpGet(t, h.base+path)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s: status=%d want 404", path, resp.StatusCode)
}
}
}
// ====== Long-poll timeout ======
func TestOAuthResultBlocksUntilCancel(t *testing.T) {
// Pending sessions must NOT respond immediately; the long-poll contract is
// that /auth/result blocks until the session completes or the client
// cancels. The 204-after-server-timeout path takes 50s so isn't asserted.
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "5.5.5.1")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.base+"/auth/result?session="+url.QueryEscape(sess), nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err == nil {
resp.Body.Close()
t.Fatalf("expected client-side cancel, got status=%d", resp.StatusCode)
}
}