// Package platform implements cdrop desktop's native capabilities — the Go // shell that the shared web UI drives over Wails bindings. // // This file owns the OAuth login flow: an RFC 8252 loopback redirect against // Casdoor with PKCE as a public client (no secret). The authorization code // exchange happens here in Go on purpose, so the PKCE verifier and the // resulting tokens never enter the WebView / JS context. package platform import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "net" "net/http" "net/url" "strings" "time" ) // OAuthConfig carries the provider coordinates. For Casdoor the token endpoint // is /api/login/oauth/access_token (non-standard) and authorize is // /login/oauth/authorize; the desktop registers as its own public client. type OAuthConfig struct { AuthorizeURL string TokenURL string ClientID string Scopes string // space-separated; empty defaults to "openid profile groups" } // TokenResult is the subset of the token response the shell keeps. IDToken is // captured so the identity can be decoded from the richer id_token claims (it's // present when the openid scope is granted — our default scopes include it). type TokenResult struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` IDToken string `json:"id_token"` ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` } // Flow runs one interactive login. openURL is injected so production uses // Wails' runtime.BrowserOpenURL while tests substitute a fake user-agent. type Flow struct { cfg OAuthConfig openURL func(string) client *http.Client timeout time.Duration } // NewFlow builds a Flow with sane defaults: a 30s HTTP client for the token // exchange and a 3-minute ceiling on the whole interactive login. func NewFlow(cfg OAuthConfig, openURL func(string)) *Flow { return &Flow{ cfg: cfg, openURL: openURL, client: &http.Client{Timeout: 30 * time.Second}, timeout: 3 * time.Minute, } } // Login performs the loopback PKCE flow and returns the exchanged tokens. // // Steps: generate verifier/challenge/state → bind an ephemeral 127.0.0.1 // listener → open the system browser at the authorize URL → wait for the // browser to hit the loopback /callback with the code → validate state → // exchange code+verifier at the token endpoint. The verifier never leaves Go. func (f *Flow) Login(ctx context.Context) (*TokenResult, error) { if f.cfg.AuthorizeURL == "" || f.cfg.TokenURL == "" || f.cfg.ClientID == "" { return nil, errors.New("oauth: incomplete config (authorize_url / token_url / client_id)") } verifier, challenge, err := newPKCE() if err != nil { return nil, fmt.Errorf("oauth: pkce: %w", err) } state, err := randString(24) if err != nil { return nil, fmt.Errorf("oauth: state: %w", err) } // RFC 8252 §7.3: bind the IPv4 loopback literal (not "localhost") on an // OS-assigned ephemeral port. ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("oauth: loopback listen: %w", err) } defer ln.Close() redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", ln.Addr().(*net.TCPAddr).Port) type cbResult struct { code string err error } resCh := make(chan cbResult, 1) send := func(r cbResult) { // non-blocking: only the first callback wins, later ones are dropped. select { case resCh <- r: default: } } mux := http.NewServeMux() mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() if e := q.Get("error"); e != "" { writeClosePage(w, false) send(cbResult{err: fmt.Errorf("oauth: authorization denied: %s", e)}) return } if q.Get("state") != state { writeClosePage(w, false) send(cbResult{err: errors.New("oauth: state mismatch (possible CSRF)")}) return } code := q.Get("code") if code == "" { writeClosePage(w, false) send(cbResult{err: errors.New("oauth: callback missing code")}) return } writeClosePage(w, true) send(cbResult{code: code}) }) srv := &http.Server{Handler: mux} go func() { _ = srv.Serve(ln) }() defer srv.Close() authURL := f.cfg.AuthorizeURL + "?" + url.Values{ "response_type": {"code"}, "client_id": {f.cfg.ClientID}, "redirect_uri": {redirectURI}, "scope": {f.scopes()}, "state": {state}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}, }.Encode() f.openURL(authURL) select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(f.timeout): return nil, errors.New("oauth: login timed out") case res := <-resCh: if res.err != nil { return nil, res.err } return f.exchange(ctx, res.code, verifier, redirectURI) } } func (f *Flow) scopes() string { if s := strings.TrimSpace(f.cfg.Scopes); s != "" { return s } return "openid profile groups" } // exchange trades the authorization code + PKCE verifier for tokens. Casdoor // accepts form-encoded public-client requests (no client_secret). authorize and // token must carry the identical redirect_uri, so we pass the same value. func (f *Flow) exchange(ctx context.Context, code, verifier, redirectURI string) (*TokenResult, error) { return f.postToken(ctx, url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "code_verifier": {verifier}, "client_id": {f.cfg.ClientID}, "redirect_uri": {redirectURI}, }) } // Refresh exchanges a refresh_token for a fresh token (public client, no // secret). Call it when the access token nears expiry; the access token never // leaves Go, so refresh is driven from the shell, not the WebView. func (f *Flow) Refresh(ctx context.Context, refreshToken string) (*TokenResult, error) { if f.cfg.TokenURL == "" || f.cfg.ClientID == "" { return nil, errors.New("oauth: incomplete config (token_url / client_id)") } if refreshToken == "" { return nil, errors.New("oauth: empty refresh token") } return f.postToken(ctx, url.Values{ "grant_type": {"refresh_token"}, "refresh_token": {refreshToken}, "client_id": {f.cfg.ClientID}, "scope": {f.scopes()}, }) } // postToken POSTs a form-encoded request to the token endpoint and decodes the // response. Shared by the authorization-code exchange and refresh. func (f *Flow) postToken(ctx context.Context, form url.Values) (*TokenResult, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.cfg.TokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("oauth: build token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := f.client.Do(req) if err != nil { return nil, fmt.Errorf("oauth: token request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("oauth: token endpoint status %d", resp.StatusCode) } var tok TokenResult if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { return nil, fmt.Errorf("oauth: decode token: %w", err) } if tok.AccessToken == "" { return nil, errors.New("oauth: token response missing access_token") } return &tok, nil } // --- PKCE (RFC 7636) --- func newPKCE() (verifier, challenge string, err error) { verifier, err = randString(64) // 43–128 chars required; 64 is comfortable if err != nil { return "", "", err } sum := sha256.Sum256([]byte(verifier)) challenge = base64.RawURLEncoding.EncodeToString(sum[:]) return verifier, challenge, nil } // randString returns n characters of base64url-encoded crypto-random data. The // base64url alphabet (A–Z a–z 0–9 - _) is a subset of the PKCE unreserved set, // so the output is a valid code_verifier / state. func randString(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } s := base64.RawURLEncoding.EncodeToString(b) if len(s) > n { s = s[:n] } return s, nil } // writeClosePage is what the browser tab lands on after the redirect. The tab // usually can't be closed by script (it wasn't script-opened), so the text is // the real affordance; the close attempt is best-effort. func writeClosePage(w http.ResponseWriter, ok bool) { w.Header().Set("Content-Type", "text/html; charset=utf-8") msg := "已登录,可关闭本页返回 cdrop。" if !ok { msg = "登录未完成,可关闭本页返回 cdrop 重试。" } fmt.Fprintf(w, `cdrop`+ ``+ `

%s

`, msg) }