package ring import ( "bytes" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "reflect" "strings" "time" ) type RefreshTokenAuth struct { RefreshToken string } type EmailAuth struct { Email string Password string } // AuthConfig represents the decoded refresh token data type AuthConfig struct { RT string `json:"rt"` // Refresh Token HID string `json:"hid"` // Hardware ID } // AuthTokenResponse represents the response from the authentication endpoint type AuthTokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token"` Scope string `json:"scope"` // Always "client" TokenType string `json:"token_type"` // Always "Bearer" } type Auth2faResponse struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` TSVState string `json:"tsv_state"` Phone string `json:"phone"` NextTimeInSecs int `json:"next_time_in_secs"` } // SocketTicketRequest represents the request to get a socket ticket type SocketTicketResponse struct { Ticket string `json:"ticket"` ResponseTimestamp int64 `json:"response_timestamp"` } // RingRestClient handles authentication and requests to Ring API type RingRestClient struct { httpClient *http.Client authConfig *AuthConfig hardwareID string authToken *AuthTokenResponse Using2FA bool PromptFor2FA string RefreshToken string auth interface{} // EmailAuth or RefreshTokenAuth onTokenRefresh func(string) } // CameraKind represents the different types of Ring cameras type CameraKind string // CameraData contains common fields for all camera types type CameraData struct { ID float64 `json:"id"` Description string `json:"description"` DeviceID string `json:"device_id"` Kind string `json:"kind"` LocationID string `json:"location_id"` } // RingDeviceType represents different types of Ring devices type RingDeviceType string // RingDevicesResponse represents the response from the Ring API type RingDevicesResponse struct { Doorbots []CameraData `json:"doorbots"` AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` StickupCams []CameraData `json:"stickup_cams"` AllCameras []CameraData `json:"all_cameras"` Chimes []CameraData `json:"chimes"` Other []map[string]interface{} `json:"other"` } const ( Doorbot CameraKind = "doorbot" Doorbell CameraKind = "doorbell" DoorbellV3 CameraKind = "doorbell_v3" DoorbellV4 CameraKind = "doorbell_v4" DoorbellV5 CameraKind = "doorbell_v5" DoorbellOyster CameraKind = "doorbell_oyster" DoorbellPortal CameraKind = "doorbell_portal" DoorbellScallop CameraKind = "doorbell_scallop" DoorbellScallopLite CameraKind = "doorbell_scallop_lite" DoorbellGraham CameraKind = "doorbell_graham_cracker" LpdV1 CameraKind = "lpd_v1" LpdV2 CameraKind = "lpd_v2" LpdV4 CameraKind = "lpd_v4" JboxV1 CameraKind = "jbox_v1" StickupCam CameraKind = "stickup_cam" StickupCamV3 CameraKind = "stickup_cam_v3" StickupCamElite CameraKind = "stickup_cam_elite" StickupCamLongfin CameraKind = "stickup_cam_longfin" StickupCamLunar CameraKind = "stickup_cam_lunar" SpotlightV2 CameraKind = "spotlightw_v2" HpCamV1 CameraKind = "hp_cam_v1" HpCamV2 CameraKind = "hp_cam_v2" StickupCamV4 CameraKind = "stickup_cam_v4" FloodlightV1 CameraKind = "floodlight_v1" FloodlightV2 CameraKind = "floodlight_v2" FloodlightPro CameraKind = "floodlight_pro" CocoaCamera CameraKind = "cocoa_camera" CocoaDoorbell CameraKind = "cocoa_doorbell" CocoaFloodlight CameraKind = "cocoa_floodlight" CocoaSpotlight CameraKind = "cocoa_spotlight" StickupCamMini CameraKind = "stickup_cam_mini" OnvifCamera CameraKind = "onvif_camera" ) const ( IntercomHandsetAudio RingDeviceType = "intercom_handset_audio" OnvifCameraType RingDeviceType = "onvif_camera" ) const ( clientAPIBaseURL = "https://api.ring.com/clients_api/" deviceAPIBaseURL = "https://api.ring.com/devices/v1/" commandsAPIBaseURL = "https://api.ring.com/commands/v1/" appAPIBaseURL = "https://prd-api-us.prd.rings.solutions/api/v1/" oauthURL = "https://oauth.ring.com/oauth/token" apiVersion = 11 defaultTimeout = 20 * time.Second maxRetries = 3 ) // NewRingRestClient creates a new Ring client instance func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { client := &RingRestClient{ httpClient: &http.Client{Timeout: defaultTimeout}, onTokenRefresh: onTokenRefresh, hardwareID: generateHardwareID(), auth: auth, } switch a := auth.(type) { case RefreshTokenAuth: if a.RefreshToken == "" { return nil, fmt.Errorf("refresh token is required") } config, err := parseAuthConfig(a.RefreshToken) if err != nil { return nil, fmt.Errorf("failed to parse refresh token: %w", err) } client.authConfig = config client.hardwareID = config.HID client.RefreshToken = a.RefreshToken case EmailAuth: if a.Email == "" || a.Password == "" { return nil, fmt.Errorf("email and password are required") } default: return nil, fmt.Errorf("invalid auth type") } return client, nil } // Request makes an authenticated request to the Ring API func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) { // Ensure we have a valid auth token if err := c.ensureAuth(); err != nil { return nil, fmt.Errorf("authentication failed: %w", err) } var bodyReader io.Reader if body != nil { jsonBody, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } bodyReader = bytes.NewReader(jsonBody) } // Create request req, err := http.NewRequest(method, url, bodyReader) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Set headers req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("hardware_id", c.hardwareID) req.Header.Set("User-Agent", "android:com.ringapp") // Make request with retries var resp *http.Response var responseBody []byte for attempt := 0; attempt <= maxRetries; attempt++ { resp, err = c.httpClient.Do(req) if err != nil { if attempt == maxRetries { return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err) } time.Sleep(5 * time.Second) continue } defer resp.Body.Close() responseBody, err = io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } // Handle 401 by refreshing auth and retrying if resp.StatusCode == http.StatusUnauthorized { c.authToken = nil // Force token refresh if attempt == maxRetries { return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) } if err := c.ensureAuth(); err != nil { return nil, fmt.Errorf("failed to refresh authentication: %w", err) } req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) continue } // Handle other error status codes if resp.StatusCode >= 400 { return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) } break } return responseBody, nil } // ensureAuth ensures we have a valid auth token func (c *RingRestClient) ensureAuth() error { if c.authToken != nil { return nil } var grantData = map[string]string{ "grant_type": "refresh_token", "refresh_token": c.authConfig.RT, } // Add common fields grantData["client_id"] = "ring_official_android" grantData["scope"] = "client" // Make auth request body, err := json.Marshal(grantData) if err != nil { return fmt.Errorf("failed to marshal auth request: %w", err) } req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) if err != nil { return fmt.Errorf("failed to create auth request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("hardware_id", c.hardwareID) req.Header.Set("User-Agent", "android:com.ringapp") req.Header.Set("2fa-support", "true") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("auth request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusPreconditionFailed { return fmt.Errorf("2FA required. Please see documentation for handling 2FA") } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) } var authResp AuthTokenResponse if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { return fmt.Errorf("failed to decode auth response: %w", err) } // Update auth config and refresh token c.authToken = &authResp c.authConfig = &AuthConfig{ RT: authResp.RefreshToken, HID: c.hardwareID, } // Encode and notify about new refresh token if c.onTokenRefresh != nil { newRefreshToken := encodeAuthConfig(c.authConfig) c.onTokenRefresh(newRefreshToken) } return nil } // getAuth makes an authentication request to the Ring API func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { var grantData map[string]string if c.authConfig != nil && twoFactorAuthCode == "" { grantData = map[string]string{ "grant_type": "refresh_token", "refresh_token": c.authConfig.RT, } } else { authEmail, ok := c.auth.(EmailAuth) if !ok { return nil, fmt.Errorf("invalid auth type for email authentication") } grantData = map[string]string{ "grant_type": "password", "username": authEmail.Email, "password": authEmail.Password, } } grantData["client_id"] = "ring_official_android" grantData["scope"] = "client" body, err := json.Marshal(grantData) if err != nil { return nil, fmt.Errorf("failed to marshal auth request: %w", err) } req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("hardware_id", c.hardwareID) req.Header.Set("User-Agent", "android:com.ringapp") req.Header.Set("2fa-support", "true") if twoFactorAuthCode != "" { req.Header.Set("2fa-code", twoFactorAuthCode) } resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // Handle 2FA Responses if resp.StatusCode == http.StatusPreconditionFailed || (resp.StatusCode == http.StatusBadRequest && strings.Contains(resp.Header.Get("WWW-Authenticate"), "Verification Code")) { var tfaResp Auth2faResponse if err := json.NewDecoder(resp.Body).Decode(&tfaResp); err != nil { return nil, err } c.Using2FA = true if resp.StatusCode == http.StatusBadRequest { c.PromptFor2FA = "Invalid 2fa code entered. Please try again." return nil, fmt.Errorf("invalid 2FA code") } if tfaResp.TSVState != "" { prompt := "from your authenticator app" if tfaResp.TSVState != "totp" { prompt = fmt.Sprintf("sent to %s via %s", tfaResp.Phone, tfaResp.TSVState) } c.PromptFor2FA = fmt.Sprintf("Please enter the code %s", prompt) } else { c.PromptFor2FA = "Please enter the code sent to your text/email" } return nil, fmt.Errorf("2FA required") } // Handle errors if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) } var authResp AuthTokenResponse if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { return nil, fmt.Errorf("failed to decode auth response: %w", err) } c.authToken = &authResp c.authConfig = &AuthConfig{ RT: authResp.RefreshToken, HID: c.hardwareID, } c.RefreshToken = encodeAuthConfig(c.authConfig) if c.onTokenRefresh != nil { c.onTokenRefresh(c.RefreshToken) } return c.authToken, nil } // Helper functions for auth config encoding/decoding func parseAuthConfig(refreshToken string) (*AuthConfig, error) { decoded, err := base64.StdEncoding.DecodeString(refreshToken) if err != nil { return nil, err } var config AuthConfig if err := json.Unmarshal(decoded, &config); err != nil { // Handle legacy format where refresh token is the raw token return &AuthConfig{RT: refreshToken}, nil } return &config, nil } func encodeAuthConfig(config *AuthConfig) string { jsonBytes, _ := json.Marshal(config) return base64.StdEncoding.EncodeToString(jsonBytes) } // API URL helpers func ClientAPI(path string) string { return clientAPIBaseURL + path } func DeviceAPI(path string) string { return deviceAPIBaseURL + path } func CommandsAPI(path string) string { return commandsAPIBaseURL + path } func AppAPI(path string) string { return appAPIBaseURL + path } // FetchRingDevices gets all Ring devices and categorizes them func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) { response, err := c.Request("GET", ClientAPI("ring_devices"), nil) if err != nil { return nil, fmt.Errorf("failed to fetch ring devices: %w", err) } var devices RingDevicesResponse if err := json.Unmarshal(response, &devices); err != nil { return nil, fmt.Errorf("failed to unmarshal devices response: %w", err) } // Process "other" devices var onvifCameras []CameraData var intercoms []CameraData for _, device := range devices.Other { kind, ok := device["kind"].(string) if !ok { continue } switch RingDeviceType(kind) { case OnvifCameraType: var camera CameraData if deviceJson, err := json.Marshal(device); err == nil { if err := json.Unmarshal(deviceJson, &camera); err == nil { onvifCameras = append(onvifCameras, camera) } } case IntercomHandsetAudio: var intercom CameraData if deviceJson, err := json.Marshal(device); err == nil { if err := json.Unmarshal(deviceJson, &intercom); err == nil { intercoms = append(intercoms, intercom) } } } } // Combine all cameras into AllCameras slice allCameras := make([]CameraData, 0) allCameras = append(allCameras, interfaceSlice(devices.Doorbots)...) allCameras = append(allCameras, interfaceSlice(devices.StickupCams)...) allCameras = append(allCameras, interfaceSlice(devices.AuthorizedDoorbots)...) allCameras = append(allCameras, interfaceSlice(onvifCameras)...) allCameras = append(allCameras, interfaceSlice(intercoms)...) devices.AllCameras = allCameras return &devices, nil } func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) { response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil) if err != nil { return nil, fmt.Errorf("failed to fetch socket ticket: %w", err) } var ticket SocketTicketResponse if err := json.Unmarshal(response, &ticket); err != nil { return nil, fmt.Errorf("failed to unmarshal socket ticket response: %w", err) } return &ticket, nil } func generateHardwareID() string { h := sha256.New() h.Write([]byte("ring-client-go2rtc")) return hex.EncodeToString(h.Sum(nil)[:16]) } func interfaceSlice(slice interface{}) []CameraData { s := reflect.ValueOf(slice) if s.Kind() != reflect.Slice { return nil } ret := make([]CameraData, s.Len()) for i := 0; i < s.Len(); i++ { if camera, ok := s.Index(i).Interface().(CameraData); ok { ret[i] = camera } } return ret }