package httpapi import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "time" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" ) // PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend // proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC // provider; the access_token still flows back to the browser unchanged. type authConfigResp struct { AuthMode string `json:"auth_mode"` AuthorizeURL string `json:"authorize_url"` // TokenURL is published so native clients (the desktop app) can run their // own loopback PKCE flow directly against the provider, reusing this same // client_id. The browser doesn't need it — it proxies through /api/auth/exchange. TokenURL string `json:"token_url"` ClientID string `json:"client_id"` RedirectURI string `json:"redirect_uri"` Scopes string `json:"scopes"` } // handleAuthConfig publishes everything the frontend needs to start the PKCE // authorize redirect. No auth required (it's all public OIDC metadata). func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, authConfigResp{ AuthMode: s.cfg.AuthMode, AuthorizeURL: s.cfg.OIDCAuthorizeURL, TokenURL: s.cfg.OIDCTokenURL, ClientID: s.cfg.OIDCClientID, RedirectURI: s.cfg.OIDCRedirectURI, Scopes: s.cfg.OIDCScopes, }) } type exchangeReq struct { Code string `json:"code"` CodeVerifier string `json:"code_verifier"` } type tokenResp 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"` } type userResp struct { ID string `json:"id"` Name string `json:"name"` Avatar string `json:"avatar,omitempty"` } type exchangeResp struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` User userResp `json:"user"` } func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) { var req exchangeReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Code == "" || req.CodeVerifier == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing code or code_verifier"}) return } if s.cfg.OIDCTokenURL == "" { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "OIDC token URL not configured"}) return } form := url.Values{} form.Set("grant_type", "authorization_code") form.Set("code", req.Code) form.Set("code_verifier", req.CodeVerifier) form.Set("client_id", s.cfg.OIDCClientID) form.Set("redirect_uri", s.cfg.OIDCRedirectURI) // Casdoor's "cdrop" application is a confidential client; PKCE is layered // on top of the standard secret. Skip the secret if not configured to keep // public-client OIDC providers happy. if s.cfg.OIDCClientSecret != "" { form.Set("client_secret", s.cfg.OIDCClientSecret) } tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form) if err != nil { slog.Warn("oidc exchange failed", "err", err) writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } user, err := extractUser(tr.IDToken, tr.AccessToken) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "extract user: " + err.Error()}) return } writeJSON(w, http.StatusOK, exchangeResp{ AccessToken: tr.AccessToken, RefreshToken: tr.RefreshToken, ExpiresIn: tr.ExpiresIn, User: user, }) } type refreshReq struct { RefreshToken string `json:"refresh_token"` } type refreshResp struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` } func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { var req refreshReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.RefreshToken == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing refresh_token"}) return } if s.cfg.OIDCTokenURL == "" { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "OIDC token URL not configured"}) return } form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("refresh_token", req.RefreshToken) form.Set("client_id", s.cfg.OIDCClientID) if s.cfg.OIDCClientSecret != "" { form.Set("client_secret", s.cfg.OIDCClientSecret) } tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form) if err != nil { slog.Warn("oidc refresh failed", "err", err) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, refreshResp{ AccessToken: tr.AccessToken, RefreshToken: tr.RefreshToken, ExpiresIn: tr.ExpiresIn, }) } var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second} func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) { req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := oidcHTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("oidc unreachable: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("oidc status %d: %s", resp.StatusCode, truncate(string(body), 200)) } var tr tokenResp if err := json.Unmarshal(body, &tr); err != nil { return nil, fmt.Errorf("oidc malformed: %w", err) } if tr.AccessToken == "" { return nil, fmt.Errorf("oidc returned no access_token") } return &tr, nil } // extractUser parses {sub, preferred_username | name} from the id_token if // available, else falls back to access_token. We DON'T verify the signature // here — the backend's auth middleware verifies access_token on every API // call via the JWKS cache, so any tamper would surface there. func extractUser(idToken, accessToken string) (userResp, error) { pick := idToken if pick == "" { pick = accessToken } parsed, err := jwt.ParseSigned(pick, []jose.SignatureAlgorithm{ jose.RS256, jose.RS384, jose.RS512, jose.ES256, jose.ES384, jose.ES512, jose.HS256, }) if err != nil { return userResp{}, err } var std jwt.Claims custom := map[string]any{} if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil { return userResp{}, err } u := userResp{ID: std.Subject} if v, ok := custom["preferred_username"].(string); ok && v != "" { u.Name = v } else if v, ok := custom["name"].(string); ok && v != "" { u.Name = v } else if v, ok := custom["email"].(string); ok && v != "" { u.Name = v } else { u.Name = u.ID } // Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段; // 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。 if v, ok := custom["avatar"].(string); ok && v != "" { u.Avatar = v } else if v, ok := custom["picture"].(string); ok && v != "" { u.Avatar = v } return u, nil } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" }