2026-04-24 09:46:45 +02:00
package main
2026-08-10 17:14:53 +02:00
// OAuth proxy for TV/headless clients. Sessions remain in memory for 10 minutes;
// client secrets are environment-only and tokens are never logged or persisted.
2026-04-24 09:46:45 +02:00
import (
"context"
"crypto/rand"
2026-07-23 02:18:03 +02:00
"crypto/sha256"
2026-04-24 09:46:45 +02:00
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
const (
2026-07-12 13:36:10 +02:00
oauthSessionTTL = 10 * time . Minute
oauthResultWait = 50 * time . Second
oauthMaxSessions = 5000
oauthStartBurst = 3
2026-04-24 09:46:45 +02:00
oauthStartRateSustained = 1
2026-08-10 17:14:53 +02:00
oauthBrowserStateBytes = 18 // 144 bits, 24 base64url characters
oauthPollSecretBytes = 18 // independent device capability
2026-07-12 13:36:10 +02:00
oauthPKCEVerifierLen = 64
oauthUpstreamTimeout = 15 * time . Second
2026-04-24 09:46:45 +02:00
)
2026-08-10 17:14:53 +02:00
// oauthServiceConfig describes one configured upstream provider.
2026-04-24 09:46:45 +02:00
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"`
}
2026-08-10 17:14:53 +02:00
// oauthSession links browser state to a device-only poll capability. Only the
// poll secret digest is retained; acquire oauthProxy.mu before s.mu.
2026-04-24 09:46:45 +02:00
type oauthSession struct {
2026-07-23 02:18:03 +02:00
browserState string
pollDigest [ sha256 . Size ] byte
2026-04-24 09:46:45 +02:00
service string
codeVerifier string // MAL PKCE; empty for AniList. Cleared after token exchange.
createdAt time . Time
done chan struct {}
2026-07-23 02:18:03 +02:00
mu sync . Mutex
completed bool
result * oauthTokenResult
2026-08-10 17:14:53 +02:00
// Test seam for deterministic concurrent waiter tests.
2026-07-23 02:18:03 +02:00
waitStarted func ()
2026-04-24 09:46:45 +02:00
}
2026-07-23 02:18:03 +02:00
// completeLocked publishes at most one terminal result. The caller holds s.mu.
func ( s * oauthSession ) completeLocked ( r oauthTokenResult ) bool {
if s . completed {
return false
2026-04-24 09:46:45 +02:00
}
2026-07-23 02:18:03 +02:00
s . completed = true
2026-04-24 09:46:45 +02:00
s . result = & r
s . codeVerifier = "" // Secret, not needed after exchange.
close ( s . done )
2026-07-23 02:18:03 +02:00
return true
2026-04-24 09:46:45 +02:00
}
2026-08-10 17:14:53 +02:00
// wait blocks until the session is ready or ctx is cancelled. Result ownership
// transfers separately through oauthProxy.claimResult.
2026-07-23 02:18:03 +02:00
func ( s * oauthSession ) wait ( ctx context . Context ) error {
if s . waitStarted != nil {
s . waitStarted ()
}
2026-04-24 09:46:45 +02:00
select {
case <- s . done :
2026-07-23 02:18:03 +02:00
return nil
2026-04-24 09:46:45 +02:00
case <- ctx . Done ():
2026-07-23 02:18:03 +02:00
return ctx . Err ()
2026-04-24 09:46:45 +02:00
}
}
2026-07-23 02:18:03 +02:00
func ( s * oauthSession ) pkceVerifier () string {
s . mu . Lock ()
defer s . mu . Unlock ()
return s . codeVerifier
}
2026-04-24 09:46:45 +02:00
type oauthProxy struct {
2026-08-10 17:14:53 +02:00
baseURL string
2026-07-23 02:18:03 +02:00
services map [ string ] oauthServiceConfig
client * http . Client
clientIPs clientIPResolver
2026-04-24 09:46:45 +02:00
2026-07-23 02:18:03 +02:00
mu sync . Mutex
browserStates map [ string ] * oauthSession
pollDigests map [[ sha256 . Size ] byte ] * oauthSession
2026-04-24 09:46:45 +02:00
ipMu sync . Mutex
ipRate map [ string ] * rateLimiter
}
2026-07-23 02:18:03 +02:00
func newOAuthProxy ( baseURL string , services map [ string ] oauthServiceConfig , clientIPs clientIPResolver ) * oauthProxy {
2026-04-24 09:46:45 +02:00
return & oauthProxy {
2026-07-23 02:18:03 +02:00
baseURL : strings . TrimRight ( baseURL , "/" ),
services : services ,
client : & http . Client { Timeout : oauthUpstreamTimeout },
clientIPs : clientIPs ,
browserStates : make ( map [ string ] * oauthSession ),
pollDigests : make ( map [[ sha256 . Size ] byte ] * oauthSession ),
ipRate : make ( map [ string ] * rateLimiter ),
2026-04-24 09:46:45 +02:00
}
}
2026-08-10 17:14:53 +02:00
// oauthConfigFromEnv returns disabled when OAUTH_BASE_URL is unset.
2026-07-23 02:18:03 +02:00
func oauthConfigFromEnv ( clientIPs clientIPResolver ) ( * oauthProxy , bool ) {
2026-04-24 09:46:45 +02:00
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" ,
}
}
2026-07-23 02:18:03 +02:00
return newOAuthProxy ( base , services , clientIPs ), true
2026-04-24 09:46:45 +02:00
}
2026-08-10 17:14:53 +02:00
// registerOAuthRoutes mounts /auth/*; a nil proxy returns 503.
2026-04-24 09:46:45 +02:00
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 )
}
2026-08-10 17:14:53 +02:00
// handleAuthRoot dispatches /auth/:service and /auth/:service/callback.
2026-04-24 09:46:45 +02:00
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 )
}
2026-08-10 17:14:53 +02:00
// POST /auth/start returns a device poll capability and authorization URL.
2026-04-24 09:46:45 +02:00
func ( p * oauthProxy ) handleStart ( w http . ResponseWriter , r * http . Request ) {
2026-07-23 02:18:03 +02:00
w . Header (). Set ( "Cache-Control" , "no-store, private" )
2026-04-24 09:46:45 +02:00
if r . Method != http . MethodPost {
http . Error ( w , "Method not allowed" , http . StatusMethodNotAllowed )
return
}
2026-07-23 02:18:03 +02:00
ip , err := p . clientIPs . resolve ( r )
if err != nil {
http . Error ( w , "Invalid client address" , http . StatusBadRequest )
return
}
2026-04-24 09:46:45 +02:00
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
}
2026-08-10 17:14:53 +02:00
// Generate trust-domain values outside the map lock; crypto/rand can block.
2026-07-23 02:18:03 +02:00
var pollSecret string
var sess * oauthSession
for {
pollSecret = randToken ( oauthPollSecretBytes )
sess = & oauthSession {
browserState : randToken ( oauthBrowserStateBytes ),
pollDigest : digestPollSecret ( pollSecret ),
service : body . Service ,
createdAt : time . Now (),
done : make ( chan struct {}),
}
if cfg . UsePKCE {
sess . codeVerifier = randPKCEVerifier ()
}
2026-04-24 09:46:45 +02:00
2026-07-23 02:18:03 +02:00
p . mu . Lock ()
if len ( p . browserStates ) >= oauthMaxSessions {
p . mu . Unlock ()
http . Error ( w , "Server busy" , http . StatusServiceUnavailable )
return
}
if p . browserStates [ sess . browserState ] != nil || p . pollDigests [ sess . pollDigest ] != nil {
p . mu . Unlock ()
continue
}
p . addSessionLocked ( sess )
2026-04-24 09:46:45 +02:00
p . mu . Unlock ()
2026-07-23 02:18:03 +02:00
break
2026-04-24 09:46:45 +02:00
}
resp := map [ string ] any {
2026-07-23 02:18:03 +02:00
"session" : pollSecret ,
"url" : fmt . Sprintf ( "%s/auth/%s?state=%s" , p . baseURL , url . PathEscape ( body . Service ), url . QueryEscape ( sess . browserState )),
2026-04-24 09:46:45 +02:00
"expiresIn" : int ( oauthSessionTTL . Seconds ()),
}
writeJSON ( w , http . StatusOK , resp )
}
2026-08-10 17:14:53 +02:00
// GET /auth/:service?state=X redirects to the upstream authorize URL.
2026-04-24 09:46:45 +02:00
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
}
2026-07-23 02:18:03 +02:00
browserState := r . URL . Query (). Get ( "state" )
2026-04-24 09:46:45 +02:00
p . mu . Lock ()
2026-07-23 02:18:03 +02:00
sess := p . browserStates [ browserState ]
2026-04-24 09:46:45 +02:00
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 )},
2026-07-23 02:18:03 +02:00
"state" : { sess . browserState },
2026-04-24 09:46:45 +02:00
}
if cfg . Scopes != "" {
q . Set ( "scope" , cfg . Scopes )
}
if cfg . UsePKCE {
2026-07-23 02:18:03 +02:00
q . Set ( "code_challenge" , sess . pkceVerifier ()) // plain method ⇒ challenge == verifier
2026-04-24 09:46:45 +02:00
q . Set ( "code_challenge_method" , cfg . PKCEMethod )
}
http . Redirect ( w , r , cfg . AuthorizeURL + "?" + q . Encode (), http . StatusFound )
}
2026-08-10 17:14:53 +02:00
// GET /auth/:service/callback exchanges the code and renders a result page.
2026-04-24 09:46:45 +02:00
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 ()
2026-07-23 02:18:03 +02:00
sess := p . browserStates [ state ]
2026-04-24 09:46:45 +02:00
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 != "" {
2026-07-23 02:18:03 +02:00
publicError := "authorization_failed"
message := "Sign-in failed. Please try again."
if upstreamErr == "access_denied" {
publicError = "access_denied"
message = "Sign-in was cancelled."
}
if ! p . completeSession ( sess , oauthTokenResult { Error : publicError }) {
renderErrorPage ( w , http . StatusNotFound , "This sign-in link is no longer valid. Start again from Plezy." )
return
}
renderErrorPage ( w , http . StatusOK , message )
2026-04-24 09:46:45 +02:00
return
}
code := q . Get ( "code" )
if code == "" {
2026-07-23 02:18:03 +02:00
if ! p . completeSession ( sess , oauthTokenResult { Error : "missing_code" }) {
renderErrorPage ( w , http . StatusNotFound , "This sign-in link is no longer valid. Start again from Plezy." )
return
}
2026-04-24 09:46:45 +02:00
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 )
2026-07-23 02:18:03 +02:00
if ! p . completeSession ( sess , oauthTokenResult { Error : "exchange_failed" }) {
renderErrorPage ( w , http . StatusNotFound , "This sign-in link is no longer valid. Start again from Plezy." )
return
}
2026-04-24 09:46:45 +02:00
renderErrorPage ( w , http . StatusBadGateway , "Couldn't complete sign-in. Please try again." )
return
}
2026-07-23 02:18:03 +02:00
if ! p . completeSession ( sess , tok ) {
renderErrorPage ( w , http . StatusNotFound , "This sign-in link is no longer valid. Start again from Plezy." )
return
}
2026-04-24 09:46:45 +02:00
renderSuccessPage ( w )
}
2026-08-10 17:14:53 +02:00
// GET /auth/result?session=X long-polls for one terminal result.
2026-04-24 09:46:45 +02:00
func ( p * oauthProxy ) handleResult ( w http . ResponseWriter , r * http . Request ) {
2026-07-23 02:18:03 +02:00
w . Header (). Set ( "Cache-Control" , "no-store, private" )
2026-04-24 09:46:45 +02:00
if r . Method != http . MethodGet {
http . Error ( w , "Method not allowed" , http . StatusMethodNotAllowed )
return
}
2026-07-23 02:18:03 +02:00
pollDigest := digestPollSecret ( r . URL . Query (). Get ( "session" ))
2026-04-24 09:46:45 +02:00
p . mu . Lock ()
2026-07-23 02:18:03 +02:00
sess := p . pollDigests [ pollDigest ]
2026-04-24 09:46:45 +02:00
p . mu . Unlock ()
if sess == nil {
http . Error ( w , "Session not found" , http . StatusGone )
return
}
ctx , cancel := context . WithTimeout ( r . Context (), oauthResultWait )
defer cancel ()
2026-07-23 02:18:03 +02:00
if err := sess . wait ( ctx ); err != nil {
2026-04-24 09:46:45 +02:00
// Client should retry — session may still receive its callback.
w . WriteHeader ( http . StatusNoContent )
return
}
2026-07-23 02:18:03 +02:00
result , ok := p . claimResult ( pollDigest , sess )
if ! ok {
http . Error ( w , "Session not found" , http . StatusGone )
return
}
2026-04-24 09:46:45 +02:00
if result . Error != "" {
writeJSON ( w , http . StatusOK , map [ string ] any { "error" : result . Error })
return
}
writeJSON ( w , http . StatusOK , result )
}
2026-08-10 17:14:53 +02:00
// GET /auth/done renders the static OAuth success page.
2026-04-24 09:46:45 +02:00
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 {
2026-07-23 02:18:03 +02:00
form . Set ( "code_verifier" , sess . pkceVerifier ())
2026-04-24 09:46:45 +02:00
}
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 )
}
2026-08-10 17:14:53 +02:00
// addSessionLocked installs both keys for one session after collision checks.
2026-07-23 02:18:03 +02:00
func ( p * oauthProxy ) addSessionLocked ( sess * oauthSession ) {
p . browserStates [ sess . browserState ] = sess
p . pollDigests [ sess . pollDigest ] = sess
}
2026-08-10 17:14:53 +02:00
// removeSessionLocked removes only entries still owned by sess.
2026-07-23 02:18:03 +02:00
func ( p * oauthProxy ) removeSessionLocked ( sess * oauthSession ) {
if p . browserStates [ sess . browserState ] == sess {
delete ( p . browserStates , sess . browserState )
}
if p . pollDigests [ sess . pollDigest ] == sess {
delete ( p . pollDigests , sess . pollDigest )
}
}
func ( p * oauthProxy ) completeSession ( sess * oauthSession , result oauthTokenResult ) bool {
p . mu . Lock ()
defer p . mu . Unlock ()
if p . browserStates [ sess . browserState ] != sess || p . pollDigests [ sess . pollDigest ] != sess {
return false
}
sess . mu . Lock ()
defer sess . mu . Unlock ()
return sess . completeLocked ( result )
}
func ( p * oauthProxy ) claimResult ( digest [ sha256 . Size ] byte , sess * oauthSession ) ( oauthTokenResult , bool ) {
p . mu . Lock ()
defer p . mu . Unlock ()
if p . pollDigests [ digest ] != sess || p . browserStates [ sess . browserState ] != sess {
return oauthTokenResult {}, false
}
sess . mu . Lock ()
defer sess . mu . Unlock ()
if sess . result == nil {
return oauthTokenResult {}, false
}
result := * sess . result
sess . result = nil
p . removeSessionLocked ( sess )
return result , true
}
2026-08-10 17:14:53 +02:00
// cleanup drops sessions past oauthSessionTTL.
2026-04-24 09:46:45 +02:00
func ( p * oauthProxy ) cleanup () {
now := time . Now ()
p . mu . Lock ()
2026-07-23 02:18:03 +02:00
for _ , sess := range p . browserStates {
2026-04-24 09:46:45 +02:00
if now . Sub ( sess . createdAt ) > oauthSessionTTL {
2026-07-23 02:18:03 +02:00
p . removeSessionLocked ( sess )
2026-04-24 09:46:45 +02:00
}
}
p . mu . Unlock ()
p . ipMu . Lock ()
2026-07-12 13:36:10 +02:00
cleanupRateLimiters ( p . ipRate , now , nil )
2026-04-24 09:46:45 +02:00
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>`
2026-08-10 17:14:53 +02:00
// Keep CSS percent literals outside Fprintf's format string.
2026-04-24 09:46:45 +02:00
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 )
}
2026-07-23 02:18:03 +02:00
func digestPollSecret ( secret string ) [ sha256 . Size ] byte {
return sha256 . Sum256 ([] byte ( secret ))
}
2026-04-24 09:46:45 +02:00
func randToken ( numBytes int ) string {
b := make ([] byte , numBytes )
if _ , err := rand . Read ( b ); err != nil {
log . Fatalf ( "crypto/rand: %v" , err )
}
return base64 . RawURLEncoding . EncodeToString ( b )
}
2026-08-10 17:14:53 +02:00
// randPKCEVerifier returns MAL's RFC 7636 §4.1 verifier alphabet.
2026-04-24 09:46:45 +02:00
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 )
}