Files
Commilitia-Drop/desktop/platform/oauth.go
T
admin f21fa5b5e8 cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。

- 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。
- 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。
- 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。
- 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。

架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

265 lines
8.6 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 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) // 43128 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 (AZ az 09 - _) 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, `<!doctype html><html lang="zh-Hans"><head><meta charset="utf-8"><title>cdrop</title></head>`+
`<body style="font-family:system-ui,sans-serif;text-align:center;margin-top:20vh">`+
`<p>%s</p><script>setTimeout(function(){window.close();},800);</script></body></html>`, msg)
}