package app import ( "bytes" "context" "crypto/rsa" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" "time" "lingniu/vehicle-data-platform/apps/api/internal/config" ) // oneOSProfile is identity data only. The profile-jump API conveys no roles, // permissions, data scope, or evidence that the upstream session remains active. type oneOSProfile struct { Issuer string `json:"-"` UserID string `json:"userId"` TenantID string `json:"tenantId"` Username string `json:"username"` Nickname string `json:"nickname"` DeptID string `json:"deptId"` DeptName string `json:"deptName"` } type oneOSProfileClient struct { baseURL string serviceToken string issuer string allowedTenant string requestTimeout time.Duration client *http.Client passwordEnabled bool passwordClientID string passwordPublicKey *rsa.PublicKey loginTicketExchangePath string loginTicketServiceToken string vehicleScopePath string } type oneOSProfileEnvelope[T any] struct { Code int `json:"code"` Data T `json:"data"` } type oneOSProfileToken struct { AccessToken string `json:"accessToken"` TokenType string `json:"tokenType"` ExpiresAt time.Time `json:"expiresAt"` } const oneOSProfileMaxResponse = 64 << 10 var errOneOSProfileRejected = errors.New("OneOS profile request rejected") func newOneOSProfileClient(cfg config.Config) (*oneOSProfileClient, error) { if !cfg.OneOSProfileEnabled && !cfg.OneOSAuthEnabled && !cfg.OneOSPasswordEnabled && cfg.OneOSLoginTicketExchangePath == "" { return nil, nil } if cfg.OneOSLocalOnly && cfg.OneOSDevIntegration { return nil, errors.New("OneOS local-only and dev-integration modes are mutually exclusive") } if cfg.OneOSDevIntegration && !validOneOSDevBaseURL(cfg.OneOSProfileBaseURL) { return nil, errors.New("local OneOS integration permits only the configured dev or production HTTPS origin") } productionOneOS := strings.TrimRight(cfg.OneOSProfileBaseURL, "/") == "https://lnoneos.com/prod-api" base, err := url.Parse(strings.TrimSpace(cfg.OneOSProfileBaseURL)) if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (!cfg.OneOSDevIntegration && !productionOneOS && base.Path != "" && base.Path != "/") { return nil, errors.New("ONEOS_PROFILE_BASE_URL must be an HTTPS origin") } loopback := net.ParseIP(base.Hostname()) != nil && net.ParseIP(base.Hostname()).IsLoopback() if cfg.OneOSLocalOnly && !loopback { return nil, errors.New("ONEOS_LOCAL_ONLY requires a literal loopback OneOS origin") } if base.Scheme != "https" && !(cfg.OneOSLocalOnly && loopback && base.Scheme == "http") { return nil, errors.New("ONEOS_PROFILE_BASE_URL must use HTTPS outside explicit local-only mode") } if cfg.OneOSProfileServiceToken != "" && (len(cfg.OneOSProfileServiceToken) < 32 || strings.TrimSpace(cfg.OneOSProfileServiceToken) != cfg.OneOSProfileServiceToken || strings.ContainsAny(cfg.OneOSProfileServiceToken, "\r\n ")) { return nil, errors.New("ONEOS_PROFILE_SERVICE_TOKEN must contain at least 32 non-whitespace characters") } if cfg.OneOSProfileEnabled && len(cfg.OneOSProfileServiceToken) < 32 { return nil, errors.New("ONEOS_PROFILE_SERVICE_TOKEN is required for profile-jump") } if err := validateOneOSConfiguredPath(cfg.OneOSLoginTicketExchangePath); err != nil { return nil, errors.New("ONEOS_LOGIN_TICKET_EXCHANGE_PATH is invalid") } if cfg.OneOSLoginTicketExchangePath != "" && (len(cfg.OneOSLoginTicketServiceToken) < 32 || strings.TrimSpace(cfg.OneOSLoginTicketServiceToken) != cfg.OneOSLoginTicketServiceToken || strings.ContainsAny(cfg.OneOSLoginTicketServiceToken, " \t\r\n")) { return nil, errors.New("ONEOS_LOGIN_TICKET_SERVICE_TOKEN must contain at least 32 non-whitespace characters") } if err := validateOneOSConfiguredPath(cfg.OneOSVehicleScopePath); err != nil { return nil, errors.New("ONEOS_VEHICLE_SCOPE_PATH is invalid") } issuer := strings.TrimSpace(cfg.OneOSProfileIssuer) tenant := strings.TrimSpace(cfg.OneOSProfileAllowedTenant) if issuer == "" || tenant == "" { return nil, errors.New("ONEOS_PROFILE_ISSUER and ONEOS_PROFILE_ALLOWED_TENANT are required") } timeout := cfg.OneOSProfileTimeout if timeout <= 0 { timeout = 3500 * time.Millisecond } if timeout >= 5*time.Second { return nil, errors.New("ONEOS_PROFILE_TIMEOUT_MS must be less than 5000") } if cfg.OneOSDevIntegration || productionOneOS { base.Path = "/prod-api" } else { base.Path = "" } var passwordPublicKey *rsa.PublicKey if cfg.OneOSPasswordEnabled { if strings.TrimSpace(cfg.OneOSPasswordClientID) == "" { return nil, errors.New("ONEOS_PASSWORD_CLIENT_ID is required") } passwordPublicKey, err = parseOneOSRSAPublicKeyForMode(cfg.OneOSPasswordRSAPublicKey, cfg.OneOSDevIntegration || productionOneOS) if err != nil { return nil, errors.New("ONEOS_PASSWORD_RSA_PUBLIC_KEY is invalid") } } if cfg.OneOSLoginTicketExchangePath != "" && strings.TrimSpace(cfg.OneOSPasswordClientID) == "" { return nil, errors.New("ONEOS_PASSWORD_CLIENT_ID is required for login-ticket exchange") } transport := http.DefaultTransport.(*http.Transport).Clone() transport.Proxy = nil transport.ResponseHeaderTimeout = timeout return &oneOSProfileClient{ baseURL: base.String(), serviceToken: cfg.OneOSProfileServiceToken, issuer: issuer, allowedTenant: tenant, requestTimeout: timeout, passwordEnabled: cfg.OneOSPasswordEnabled, passwordClientID: cfg.OneOSPasswordClientID, passwordPublicKey: passwordPublicKey, loginTicketExchangePath: cfg.OneOSLoginTicketExchangePath, loginTicketServiceToken: cfg.OneOSLoginTicketServiceToken, vehicleScopePath: cfg.OneOSVehicleScopePath, client: &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, }, nil } // ExchangeCode consumes a code exactly once, then reads its bound profile. // The whole exchange shares one deadline; a failed call is never retried. func (c *oneOSProfileClient) ExchangeCode(ctx context.Context, code string) (oneOSProfile, error) { if c == nil || c.serviceToken == "" { return oneOSProfile{}, errors.New("OneOS profile client is disabled") } decoded, err := base64.RawURLEncoding.DecodeString(code) if len(code) != 43 || err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != code { return oneOSProfile{}, errors.New("invalid OneOS profile code") } ctx, cancel := context.WithTimeout(ctx, c.requestTimeout) defer cancel() body, _ := json.Marshal(struct { Code string `json:"code"` }{code}) var token oneOSProfileToken if err := c.doJSON(ctx, http.MethodPost, "/auth/inner/v1/sso/profile-jump/exchange", body, c.serviceToken, &token); err != nil { return oneOSProfile{}, err } if !validOneOSOpaqueToken(token.AccessToken) || token.TokenType != "Bearer" || !token.ExpiresAt.After(time.Now().Add(time.Second)) { return oneOSProfile{}, errors.New("invalid OneOS profile token response") } var profile oneOSProfile if err := c.doJSON(ctx, http.MethodGet, "/auth/sso/profile-jump/me", nil, token.AccessToken, &profile); err != nil { return oneOSProfile{}, err } if strings.TrimSpace(profile.UserID) == "" || strings.TrimSpace(profile.TenantID) == "" || profile.TenantID != c.allowedTenant { return oneOSProfile{}, errors.New("OneOS profile identity or tenant rejected") } profile.Issuer = c.issuer return profile, nil } func validOneOSOpaqueToken(value string) bool { decoded, err := base64.RawURLEncoding.DecodeString(value) return len(value) == 43 && err == nil && len(decoded) == 32 && base64.RawURLEncoding.EncodeToString(decoded) == value } func (c *oneOSProfileClient) doJSON(ctx context.Context, method, path string, body []byte, bearer string, target any) error { request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body)) if err != nil { return errors.New("invalid OneOS request") } if bearer != "" { request.Header.Set("Authorization", "Bearer "+bearer) } request.Header.Set("Accept", "application/json") if method == http.MethodPost { request.Header.Set("Content-Type", "application/json") } response, err := c.client.Do(request) if err != nil { return fmt.Errorf("%w: transport failure", errOneOSProfileRejected) } defer response.Body.Close() if response.StatusCode != http.StatusOK { return fmt.Errorf("%w: HTTP %d", errOneOSProfileRejected, response.StatusCode) } raw, err := io.ReadAll(io.LimitReader(response.Body, oneOSProfileMaxResponse+1)) if err != nil || len(raw) > oneOSProfileMaxResponse { return errors.New("invalid OneOS profile response") } var envelope oneOSProfileEnvelope[json.RawMessage] if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 || len(envelope.Data) == 0 || string(envelope.Data) == "null" { return errors.New("invalid OneOS profile response") } if err := json.Unmarshal(envelope.Data, target); err != nil { return errors.New("invalid OneOS profile response") } return nil }