f21fa5b5e8
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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
105 lines
3.2 KiB
Go
105 lines
3.2 KiB
Go
package platform
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// UserInfo is the display identity the WebView store needs (mirrors the web
|
|
// app's User shape: id / name / avatar).
|
|
type UserInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Avatar string `json:"avatar"`
|
|
}
|
|
|
|
// LoginResult is the full result Go keeps internally: tokens + identity. The
|
|
// refresh_token never crosses into the WebView — see SessionView and the
|
|
// credential policy in session.go.
|
|
type LoginResult struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
User UserInfo `json:"user"`
|
|
}
|
|
|
|
// SessionView is the refresh-token-free projection handed to the WebView (login
|
|
// emit, refresh return, boot injection). The refresh_token — the long-lived
|
|
// secret — is deliberately omitted: it lives only in the Go process and the
|
|
// on-disk session, never in JS or the injected HTML.
|
|
type SessionView struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
User UserInfo `json:"user"`
|
|
}
|
|
|
|
// View projects a LoginResult to the refresh-free SessionView.
|
|
func (r LoginResult) View() SessionView {
|
|
return SessionView{AccessToken: r.AccessToken, ExpiresIn: r.ExpiresIn, User: r.User}
|
|
}
|
|
|
|
// UserFromToken extracts the display identity from OIDC tokens WITHOUT verifying
|
|
// the signature. This mirrors the backend's extractUser (internal/httpapi/auth.go)
|
|
// exactly: prefer the id_token's richer claims, fall back to the access_token;
|
|
// name priority preferred_username → name → email → sub; avatar avatar → picture.
|
|
//
|
|
// Skipping verification is safe for the same reason it is on the backend: the
|
|
// auth middleware verifies the access_token on every API call via JWKS, so any
|
|
// tamper surfaces there. These claims only drive local display.
|
|
func UserFromToken(idToken, accessToken string) (UserInfo, error) {
|
|
pick := idToken
|
|
if pick == "" {
|
|
pick = accessToken
|
|
}
|
|
claims, err := decodeJWTClaims(pick)
|
|
if err != nil {
|
|
return UserInfo{}, err
|
|
}
|
|
|
|
u := UserInfo{ID: claimString(claims, "sub")}
|
|
for _, k := range []string{"preferred_username", "name", "email"} {
|
|
if v := claimString(claims, k); v != "" {
|
|
u.Name = v
|
|
break
|
|
}
|
|
}
|
|
if u.Name == "" {
|
|
u.Name = u.ID
|
|
}
|
|
for _, k := range []string{"avatar", "picture"} {
|
|
if v := claimString(claims, k); v != "" {
|
|
u.Avatar = v
|
|
break
|
|
}
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// decodeJWTClaims base64url-decodes the JWT payload segment and unmarshals it.
|
|
// No signature check (see UserFromToken).
|
|
func decodeJWTClaims(token string) (map[string]any, error) {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) < 2 {
|
|
return nil, errors.New("oauth: token is not a JWT")
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oauth: decode jwt payload: %w", err)
|
|
}
|
|
var claims map[string]any
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return nil, fmt.Errorf("oauth: parse jwt claims: %w", err)
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
func claimString(m map[string]any, key string) string {
|
|
if v, ok := m[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|