262 lines
6.3 KiB
Markdown
262 lines
6.3 KiB
Markdown
# Kosmi WebSocket Authentication - Reverse Engineering Findings
|
|
|
|
**Date**: October 31, 2025
|
|
**Status**: ✅ Authentication mechanism fully reverse engineered
|
|
|
|
## Summary
|
|
|
|
The Kosmi WebSocket API (`wss://engine.kosmi.io/gql-ws`) requires a JWT token that is obtained via an HTTP POST request. The token is then sent in the `connection_init` message payload.
|
|
|
|
## Authentication Flow
|
|
|
|
```
|
|
1. Browser visits: https://app.kosmi.io/room/@roomname
|
|
↓
|
|
2. JavaScript makes POST to: https://engine.kosmi.io/
|
|
↓
|
|
3. Server returns JWT token (valid for 1 year!)
|
|
↓
|
|
4. JavaScript connects WebSocket: wss://engine.kosmi.io/gql-ws
|
|
↓
|
|
5. Sends connection_init with token
|
|
↓
|
|
6. Server responds with connection_ack
|
|
↓
|
|
7. Ready to send GraphQL subscriptions/mutations
|
|
```
|
|
|
|
## Key Findings
|
|
|
|
### 1. JWT Token Structure
|
|
|
|
The token is a standard JWT with the following payload:
|
|
|
|
```json
|
|
{
|
|
"aud": "kosmi",
|
|
"exp": 1793367309,
|
|
"iat": 1761917709,
|
|
"iss": "kosmi",
|
|
"jti": "c824a175-46e6-4ffc-b69a-42f319d62460",
|
|
"nbf": 1761917708,
|
|
"sub": "a067ec32-ad5c-4831-95cc-0f88bdb33587",
|
|
"typ": "access"
|
|
}
|
|
```
|
|
|
|
**Important fields**:
|
|
- `sub`: Anonymous user ID (UUID)
|
|
- `exp`: Expiration timestamp (1 year from issuance!)
|
|
- `typ`: "access" token type
|
|
|
|
### 2. Token Acquisition
|
|
|
|
**Request**:
|
|
```http
|
|
POST https://engine.kosmi.io/
|
|
Content-Type: application/json
|
|
Referer: https://app.kosmi.io/
|
|
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
|
|
```
|
|
|
|
**Body**: (Likely a GraphQL query/mutation for anonymous login or session creation)
|
|
|
|
**Response**: Returns JWT token (details to be captured in next test)
|
|
|
|
### 3. WebSocket Connection Init
|
|
|
|
**WebSocket URL**: `wss://engine.kosmi.io/gql-ws`
|
|
|
|
**Protocol**: `graphql-ws`
|
|
|
|
**First message (connection_init)**:
|
|
```json
|
|
{
|
|
"type": "connection_init",
|
|
"payload": {
|
|
"token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9...",
|
|
"ua": "TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzEyMC4wLjAuMCBTYWZhcmkvNTM3LjM2",
|
|
"v": "4364",
|
|
"r": ""
|
|
}
|
|
}
|
|
```
|
|
|
|
**Payload fields**:
|
|
- `token`: The JWT access token
|
|
- `ua`: Base64-encoded User-Agent string
|
|
- `v`: Version number "4364" (app version?)
|
|
- `r`: Empty string (possibly room-related, unused for anonymous)
|
|
|
|
**Response**:
|
|
```json
|
|
{
|
|
"type": "connection_ack",
|
|
"payload": {}
|
|
}
|
|
```
|
|
|
|
### 4. Required Headers
|
|
|
|
**For POST request**:
|
|
```go
|
|
headers := http.Header{
|
|
"Content-Type": []string{"application/json"},
|
|
"Referer": []string{"https://app.kosmi.io/"},
|
|
"User-Agent": []string{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
|
|
}
|
|
```
|
|
|
|
**For WebSocket**:
|
|
```go
|
|
headers := http.Header{
|
|
"Origin": []string{"https://app.kosmi.io"},
|
|
"User-Agent": []string{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
|
|
}
|
|
|
|
dialer := websocket.Dialer{
|
|
Subprotocols: []string{"graphql-ws"},
|
|
}
|
|
```
|
|
|
|
### 5. Cookies
|
|
|
|
**Cookie found**: `g_state`
|
|
|
|
**Value structure**:
|
|
```json
|
|
{
|
|
"i_l": 0,
|
|
"i_ll": 1761917710911,
|
|
"i_b": "w4+5eCKfslo5DMEmBzdtPYztYGoOkFIbwBzrc4xEzDk"
|
|
}
|
|
```
|
|
|
|
**Purpose**: Appears to be Google Analytics state or similar tracking.
|
|
|
|
**Required for WebSocket?**: ❌ NO - The JWT token is the primary authentication mechanism.
|
|
|
|
## Implementation Strategy
|
|
|
|
### Step 1: Token Acquisition
|
|
|
|
We need to reverse engineer the POST request body. Two approaches:
|
|
|
|
**Option A: Capture the POST body**
|
|
- Modify capture tool to log request bodies
|
|
- See exactly what GraphQL query/mutation is sent
|
|
|
|
**Option B: Test common patterns**
|
|
- Try empty body: `{}`
|
|
- Try anonymous login mutation
|
|
- Try session creation query
|
|
|
|
### Step 2: Native Client Implementation
|
|
|
|
```go
|
|
type NativeClient struct {
|
|
httpClient *http.Client
|
|
wsConn *websocket.Conn
|
|
token string
|
|
roomID string
|
|
log *logrus.Entry
|
|
}
|
|
|
|
func (c *NativeClient) Connect() error {
|
|
// 1. Get JWT token
|
|
token, err := c.acquireToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.token = token
|
|
|
|
// 2. Connect WebSocket
|
|
dialer := websocket.Dialer{
|
|
Subprotocols: []string{"graphql-ws"},
|
|
}
|
|
|
|
headers := http.Header{
|
|
"Origin": []string{"https://app.kosmi.io"},
|
|
"User-Agent": []string{userAgent},
|
|
}
|
|
|
|
conn, _, err := dialer.Dial("wss://engine.kosmi.io/gql-ws", headers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.wsConn = conn
|
|
|
|
// 3. Send connection_init
|
|
return c.sendConnectionInit()
|
|
}
|
|
|
|
func (c *NativeClient) acquireToken() (string, error) {
|
|
// POST to https://engine.kosmi.io/
|
|
// Parse response for JWT token
|
|
}
|
|
|
|
func (c *NativeClient) sendConnectionInit() error {
|
|
uaEncoded := base64.StdEncoding.EncodeToString([]byte(userAgent))
|
|
|
|
msg := map[string]interface{}{
|
|
"type": "connection_init",
|
|
"payload": map[string]interface{}{
|
|
"token": c.token,
|
|
"ua": uaEncoded,
|
|
"v": "4364",
|
|
"r": "",
|
|
},
|
|
}
|
|
|
|
return c.wsConn.WriteJSON(msg)
|
|
}
|
|
```
|
|
|
|
## Next Steps
|
|
|
|
1. ✅ Capture token acquisition POST body
|
|
2. ✅ Implement `acquireToken()` function
|
|
3. ✅ Test direct WebSocket connection with token
|
|
4. ✅ Verify message subscription works
|
|
5. ✅ Verify message sending works
|
|
6. ✅ Replace ChromeDP client
|
|
|
|
## Success Criteria
|
|
|
|
- [ ] Can obtain JWT token without browser
|
|
- [ ] Can connect WebSocket with just token
|
|
- [ ] Can receive messages
|
|
- [ ] Can send messages
|
|
- [ ] No ChromeDP dependency
|
|
- [ ] < 50MB RAM usage
|
|
- [ ] < 1 second startup time
|
|
|
|
## Additional Notes
|
|
|
|
### Token Validity
|
|
|
|
The JWT token has a **1-year expiration**! This means:
|
|
- We can cache the token
|
|
- No need to re-authenticate frequently
|
|
- Simplifies the implementation significantly
|
|
|
|
### Anonymous Access
|
|
|
|
The Kosmi API supports **true anonymous access**:
|
|
- No login credentials needed
|
|
- Just POST to get a token
|
|
- Token is tied to an anonymous user UUID
|
|
|
|
This is excellent news for our use case!
|
|
|
|
### No Cookies Required
|
|
|
|
Unlike our initial assumption, **cookies are NOT required** for WebSocket authentication. The JWT token in the `connection_init` payload is sufficient.
|
|
|
|
## References
|
|
|
|
- Captured data: `auth-data.json`
|
|
- Capture tool: `cmd/capture-auth/main.go`
|
|
- Chrome extension (reference): `.examples/chrome-extension/inject.js`
|
|
|