package httpapi import ( "context" "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" "commilitia.net/cdrop/internal/db" ) // 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 browser is then handed a cdrop self-signed access token (sid-bound // to its web_sessions row), not the IdP's RS256 token, so OIDC web sessions verify // statefully and revoke immediately — unified with scan-login (AUTH.md §5). 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"` } // exchangeResp deliberately omits refresh_token: the durable credential never // reaches the browser. It's encrypted into the server-side web_sessions row and // represented to the client only by the HttpOnly session cookie set alongside. type exchangeResp struct { AccessToken string `json:"access_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 } identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "extract user: " + err.Error()}) return } user := identity.User // Refresh the thin accounts row (display name / avatar / roles / match_key). s.upsertAccount(r.Context(), identity) // By default the browser holds a cdrop self-signed access token (sid-bound), // not the IdP's RS256 token — so the OIDC web session is verified statefully on // every request (verifySelfToken → web_sessions lookup) and revoking the row // logs this device out on its very next call, exactly like scan-login. We mint // it only after stashing the refresh_token server-side (encrypted, behind an // HttpOnly cookie) so the session is durable. The IdP RS256 token stays the // fallback for degraded configs (no encryption key / no refresh_token / an // id_token that fails verification) — it is still independently verified per // request via JWKS (verifyRS256), and the next refresh upgrades the device to // the unified self-token model. (AUTH.md §5; desktop keeps its RS256 token.) accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn if len(s.sessionKey) > 0 && tr.RefreshToken != "" { id, cerr := s.createWebSession(r, w, user.ID, tr.RefreshToken) if cerr != nil { slog.Error("create web session failed", "err", cerr) } else { // The id_token is the SOLE trust anchor for the minted self token (the // IdP RS256 token no longer reaches the browser to be re-verified), so // its signature MUST validate here and its subject must match. On // failure we keep the durable session but hand back the per-request- // verified RS256 token; the next refresh upgrades this device to the // self-token model. (Should not happen with a healthy IdP — warn ops.) sub, _, verr := s.auth.VerifyIDToken(r.Context(), tr.IDToken) if verr != nil || sub != user.ID { slog.Warn("oidc id_token verification failed; using RS256 fallback", "err", verr, "sub_match", sub == user.ID) } else if tok, ein, merr := s.mintSessionToken(user.ID, id, "full"); merr != nil { slog.Error("mint session token failed", "err", merr) } else { accessToken, expiresIn = tok, ein } } } writeJSON(w, http.StatusOK, exchangeResp{ AccessToken: accessToken, ExpiresIn: expiresIn, User: user, }) } // createWebSession encrypts the refresh_token, persists a new session row, and // sets the session cookie on the response. Returns the session row id so the // caller can bind a cdrop self-signed access token to it (sid). func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) (string, error) { raw, id, err := newSessionToken() if err != nil { return "", err } enc, err := s.encryptRefresh(refreshToken) if err != nil { return "", err } now := time.Now() if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{ ID: id, UserID: userID, RefreshToken: enc, DeviceName: "", UserAgent: truncate(r.UserAgent(), 256), CreatedAt: now.Unix(), LastUsedAt: now.Unix(), ExpiresAt: now.Add(webSessionTTL).Unix(), }); err != nil { return "", err } setSessionCookie(w, raw) return id, nil } // refreshResp is what the browser sees: a fresh access_token plus the identity // and device name recovered from the session. No refresh_token — it stays // server-side. This same endpoint, called with the cookie and no body on app // boot, IS the passwordless re-login path. type refreshResp struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` User userResp `json:"user"` DeviceName string `json:"device_name"` } func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { if s.cfg.OIDCTokenURL == "" { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "OIDC token URL not configured"}) return } if !s.sameOrigin(r) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) return } c, err := r.Cookie(sessionCookieName) if err != nil || c.Value == "" { clearSessionCookie(w) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"}) return } id := sessionID(c.Value) // Serialise concurrent refreshes of this session, then read the row INSIDE the // lock: a racing tab may already have rotated the refresh_token, and we must // spend the current one, not a stale copy the IdP would reject. unlock := s.lockRefresh(id) defer unlock() sess, err := s.queries.GetWebSession(r.Context(), id) if err != nil { // Unknown / deleted session → not authenticated. Clear the stale cookie. clearSessionCookie(w) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"}) return } now := time.Now() if sess.ExpiresAt < now.Unix() { _ = s.queries.DeleteWebSession(r.Context(), id) clearSessionCookie(w) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "session expired"}) return } // Self-signed sessions (scan-login: kind self/guest) carry no IdP refresh_token. // Mint a fresh cdrop access token straight from the row, slide the window for // persistent sessions, re-arm the cookie — never touching the IdP (AUTH.md §3.1). if sess.Kind != "oidc" { s.refreshSelfSession(w, r, sess, c.Value, now) return } refreshToken, err := s.decryptRefresh(sess.RefreshToken) if err != nil { // Corrupt ciphertext or rotated key — the session is unusable, drop it. _ = s.queries.DeleteWebSession(r.Context(), id) clearSessionCookie(w) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"}) return } form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("refresh_token", 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 { // The IdP rejected the refresh_token (expired / revoked) — the session is // dead end to end, so destroy it rather than leave a row that always 401s. slog.Warn("oidc refresh failed", "err", err) _ = s.queries.DeleteWebSession(r.Context(), id) clearSessionCookie(w) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "refresh rejected"}) return } identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "extract user: " + err.Error()}) return } user := identity.User // Keep the thin accounts row fresh on every successful refresh too. s.upsertAccount(r.Context(), identity) // Rotate: Casdoor issues a new refresh_token on each grant; fall back to the // existing one if it didn't. Slide the window forward and re-arm the cookie. newRefresh := tr.RefreshToken if newRefresh == "" { newRefresh = refreshToken } if enc, encErr := s.encryptRefresh(newRefresh); encErr == nil { if err := s.queries.RotateWebSession(r.Context(), db.RotateWebSessionParams{ RefreshToken: enc, LastUsedAt: now.Unix(), ExpiresAt: now.Add(webSessionTTL).Unix(), ID: id, }); err != nil { slog.Error("rotate web session failed", "err", err) } } else { slog.Error("encrypt rotated refresh token failed", "err", encErr) } setSessionCookie(w, c.Value) // Hand back a cdrop self-signed access token (sid-bound), not the IdP RS256 // token: the browser holds one uniform token type and revoking the session row // logs this device out on its next request. The IdP round-trip above still ran // — it rotated the refresh_token and surfaced IdP-side revocation (a rejected // refresh deletes the session above) — its access_token simply no longer ships. // Mint from the row's canonical user_id; degrade to the IdP token only if the // HS256 key is unconfigured. (AUTH.md §5.) accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn if tok, ein, mErr := s.mintSessionToken(sess.UserID, id, "full"); mErr != nil { slog.Error("mint session token failed", "err", mErr) } else { accessToken, expiresIn = tok, ein } writeJSON(w, http.StatusOK, refreshResp{ AccessToken: accessToken, ExpiresIn: expiresIn, User: user, DeviceName: sess.DeviceName, }) } // refreshSelfSession renews a cdrop self-signed session without contacting the // IdP: it mints a fresh access token from the row. Persistent "self" sessions // slide their inactivity window; one-time "guest" borrows do not (their fixed // expires_at caps the borrow at QR_GUEST_TTL). Identity is enriched from the // accounts row when present, else falls back to the bare user_id. func (s *Server) refreshSelfSession(w http.ResponseWriter, r *http.Request, sess db.WebSession, rawCookie string, now time.Time) { token, expiresIn, err := s.mintSessionToken(sess.UserID, sess.ID, sess.Scope) if err != nil { slog.Error("mint session token failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"}) return } if sess.Kind == "self" { newExp := now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour) if err := s.queries.SlideWebSession(r.Context(), db.SlideWebSessionParams{ LastUsedAt: now.Unix(), ExpiresAt: newExp.Unix(), ID: sess.ID, }); err != nil { slog.Error("slide web session failed", "err", err) } setSessionCookie(w, rawCookie) } user := userResp{ID: sess.UserID, Name: sess.UserID} if acct, err := s.queries.GetAccount(r.Context(), sess.UserID); err == nil { if acct.DisplayName != "" { user.Name = acct.DisplayName } user.Avatar = acct.AvatarUrl } writeJSON(w, http.StatusOK, refreshResp{ AccessToken: token, ExpiresIn: expiresIn, User: user, DeviceName: sess.DeviceName, }) } 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 } // tokenIdentity is what cdrop reads out of an OIDC id_token at login: the display // identity plus the thin-account fields — match_key (for the admin-gated migration // relink) and roles (the groups claim cache). AUTH.md §2.1. type tokenIdentity struct { User userResp MatchKey string Roles []string } // extractIdentity parses {sub, preferred_username | name | email, avatar | picture, // matchClaim, groups} from the id_token if available, else the access_token. The // signature is NOT verified here — callers verify separately around it: at login // (handleAuthExchange) the id_token signature is checked via VerifyIDToken before // the extracted subject is trusted to mint a self token; at refresh the IdP just // authenticated the refresh_token over a TLS back-channel, so the token it returns // is trusted by transport. Either way the extracted fields are sound. (AUTH.md §5.) func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, 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 tokenIdentity{}, err } var std jwt.Claims custom := map[string]any{} if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil { return tokenIdentity{}, err } id := tokenIdentity{User: userResp{ID: std.Subject}} if v, ok := custom["preferred_username"].(string); ok && v != "" { id.User.Name = v } else if v, ok := custom["name"].(string); ok && v != "" { id.User.Name = v } else if v, ok := custom["email"].(string); ok && v != "" { id.User.Name = v } else { id.User.Name = id.User.ID } // Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段; // 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。 if v, ok := custom["avatar"].(string); ok && v != "" { id.User.Avatar = v } else if v, ok := custom["picture"].(string); ok && v != "" { id.User.Avatar = v } // match_key feeds the admin-gated cross-provider relink; pick the configured // claim (default email). Absent / non-string → empty, which simply never matches. if matchClaim != "" { if v, ok := custom[matchClaim].(string); ok { id.MatchKey = v } } if g, ok := custom["groups"].([]any); ok { for _, item := range g { if r, ok := item.(string); ok && r != "" { id.Roles = append(id.Roles, r) } } } return id, nil } // upsertAccount refreshes the caller's thin accounts row from their OIDC identity // (AUTH.md §2.1). Best-effort — a write failure must never fail the login itself. func (s *Server) upsertAccount(ctx context.Context, id tokenIdentity) { if id.User.ID == "" { return } now := time.Now().Unix() if err := s.queries.UpsertAccount(ctx, db.UpsertAccountParams{ UserID: id.User.ID, MatchKey: id.MatchKey, DisplayName: id.User.Name, AvatarUrl: id.User.Avatar, Roles: strings.Join(id.Roles, ","), Provider: "oidc", CreatedAt: now, LastLoginAt: now, }); err != nil { slog.Error("upsert account failed", "err", err, "user", id.User.ID) } } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" }