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 "" }