|
| 1 | +#!/bin/bash |
| 2 | +# MetLife Pet Insurance API helper |
| 3 | +# Source this at the start of every Bash tool call that needs API access. |
| 4 | +# All secrets (tokens) stay in .metlife-cache/ which is gitignored. |
| 5 | + |
| 6 | +METLIFE_CACHE_DIR=".metlife-cache" |
| 7 | +TOKEN_FILE="$METLIFE_CACHE_DIR/.token" |
| 8 | + |
| 9 | +# Public client keys (embedded in mypets.metlife.com SPA JavaScript) |
| 10 | +APIM_KEY="979fd0c2ea204f1095d7faa8154c39b0" |
| 11 | +IBM_CLIENT_ID="634dc387-c737-4a6a-86ef-f49056e30898" |
| 12 | +CHANNEL_ID="PetMobile" |
| 13 | +APP_VERSION="4.5.1" |
| 14 | +ORIGIN="https://mypets.metlife.com" |
| 15 | +REFERER="https://mypets.metlife.com/" |
| 16 | + |
| 17 | +# Gateway base URLs |
| 18 | +APIM_BASE="https://apis.metlife.com/external/pet-services" |
| 19 | +IBM_BASE="https://api.metlife.com/metlife/production/api/pet-services/pingv2" |
| 20 | + |
| 21 | +# ── Utilities ──────────────────────────────────────────────────────────────── |
| 22 | + |
| 23 | +_session_id() { |
| 24 | + python3 -c "import time,random,string; print(str(int(time.time()*1000))+''.join(random.choices(string.ascii_lowercase,k=3)))" |
| 25 | +} |
| 26 | + |
| 27 | +_pkce() { |
| 28 | + python3 -c "import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode())" |
| 29 | +} |
| 30 | + |
| 31 | +# ── Token storage ──────────────────────────────────────────────────────────── |
| 32 | +# File format (4 lines): bearer_token / refresh_token / expiry_epoch / session_id |
| 33 | + |
| 34 | +load_tokens() { |
| 35 | + METLIFE_BEARER_TOKEN=$(sed -n '1p' "$TOKEN_FILE" 2>/dev/null) |
| 36 | + METLIFE_REFRESH_TOKEN=$(sed -n '2p' "$TOKEN_FILE" 2>/dev/null) |
| 37 | + METLIFE_TOKEN_EXPIRY=$(sed -n '3p' "$TOKEN_FILE" 2>/dev/null) |
| 38 | + METLIFE_SESSION_ID=$(sed -n '4p' "$TOKEN_FILE" 2>/dev/null) |
| 39 | + [ -z "$METLIFE_SESSION_ID" ] && METLIFE_SESSION_ID=$(_session_id) |
| 40 | +} |
| 41 | + |
| 42 | +save_tokens() { |
| 43 | + mkdir -p "$METLIFE_CACHE_DIR" |
| 44 | + printf '%s\n%s\n%s\n%s\n' \ |
| 45 | + "$METLIFE_BEARER_TOKEN" \ |
| 46 | + "$METLIFE_REFRESH_TOKEN" \ |
| 47 | + "$METLIFE_TOKEN_EXPIRY" \ |
| 48 | + "$METLIFE_SESSION_ID" > "$TOKEN_FILE" |
| 49 | +} |
| 50 | + |
| 51 | +token_is_expired() { |
| 52 | + [ -z "$METLIFE_BEARER_TOKEN" ] || [ -z "$METLIFE_TOKEN_EXPIRY" ] && return 0 |
| 53 | + local now; now=$(date +%s) |
| 54 | + [ "$now" -ge $((METLIFE_TOKEN_EXPIRY - 120)) ] |
| 55 | +} |
| 56 | + |
| 57 | +# ── Login ──────────────────────────────────────────────────────────────────── |
| 58 | +# Usage: metlife_login "email" "password" |
| 59 | + |
| 60 | +metlife_login() { |
| 61 | + local username="$1" password="$2" |
| 62 | + [ -z "$username" ] || [ -z "$password" ] && { echo "ERROR: Username and password required" >&2; return 1; } |
| 63 | + |
| 64 | + METLIFE_SESSION_ID=$(_session_id) |
| 65 | + local cv; cv=$(_pkce) |
| 66 | + |
| 67 | + # Step 1: POST /authentication/login |
| 68 | + echo "Logging in..." >&2 |
| 69 | + local body |
| 70 | + body=$(python3 -c "import json,sys; print(json.dumps({'userName':sys.argv[1],'password':sys.argv[2],'codeChallenge':sys.argv[3]}))" "$username" "$password" "$cv") |
| 71 | + |
| 72 | + local r1 |
| 73 | + r1=$(curl -s "$APIM_BASE/authentication/login" \ |
| 74 | + -H 'content-type: application/json' \ |
| 75 | + -H "channel-id: $CHANNEL_ID" \ |
| 76 | + -H 'is-ping-token: true' \ |
| 77 | + -H "ocp-apim-subscription-key: $APIM_KEY" \ |
| 78 | + -H "origin: $ORIGIN" -H "referer: $REFERER" \ |
| 79 | + -H "session-id: $METLIFE_SESSION_ID" \ |
| 80 | + -H "transaction-id: $METLIFE_SESSION_ID" \ |
| 81 | + -H "x-app-version: $APP_VERSION" \ |
| 82 | + -H 'cache-control: no-cache, no-store' \ |
| 83 | + --data-raw "$body" 2>/dev/null) |
| 84 | + |
| 85 | + local flow_id corr_id status |
| 86 | + read -r flow_id corr_id status < <(echo "$r1" | python3 -c " |
| 87 | +import sys,json |
| 88 | +d=json.load(sys.stdin) |
| 89 | +print(d.get('id',''), d.get('extension',{}).get('correlationId',''), d.get('status','')) |
| 90 | +" 2>/dev/null) |
| 91 | + |
| 92 | + if [ -z "$flow_id" ]; then |
| 93 | + echo "ERROR: Login failed. Response: $r1" >&2 |
| 94 | + return 1 |
| 95 | + fi |
| 96 | + echo "Login step 1 OK (status: $status). Completing auth..." >&2 |
| 97 | + |
| 98 | + # Brief pause — server needs a moment after device profile check |
| 99 | + sleep 2 |
| 100 | + |
| 101 | + # Step 2: POST /authentication/profileDevice |
| 102 | + local body2 |
| 103 | + body2=$(python3 -c "import json,sys; print(json.dumps({'flowId':sys.argv[1],'codeVerifier':sys.argv[2],'extension':{'correlationId':sys.argv[3]}}))" "$flow_id" "$cv" "$corr_id") |
| 104 | + |
| 105 | + local r2 |
| 106 | + r2=$(curl -s "$APIM_BASE/authentication/profileDevice" \ |
| 107 | + -H 'content-type: application/json' \ |
| 108 | + -H "channel-id: $CHANNEL_ID" \ |
| 109 | + -H 'is-ping-token: true' \ |
| 110 | + -H "ocp-apim-subscription-key: $APIM_KEY" \ |
| 111 | + -H "origin: $ORIGIN" -H "referer: $REFERER" \ |
| 112 | + -H "session-id: $METLIFE_SESSION_ID" \ |
| 113 | + -H "transaction-id: $METLIFE_SESSION_ID" \ |
| 114 | + -H "x-app-version: $APP_VERSION" \ |
| 115 | + -H 'cache-control: no-cache, no-store' \ |
| 116 | + --data-raw "$body2" 2>/dev/null) |
| 117 | + |
| 118 | + local auth_status |
| 119 | + auth_status=$(echo "$r2" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null) |
| 120 | + if [ "$auth_status" != "COMPLETED" ]; then |
| 121 | + echo "ERROR: Auth failed (status: $auth_status). Response: $r2" >&2 |
| 122 | + return 1 |
| 123 | + fi |
| 124 | + |
| 125 | + # Extract tokens |
| 126 | + local now; now=$(date +%s) |
| 127 | + read -r METLIFE_BEARER_TOKEN METLIFE_REFRESH_TOKEN expires_in < <(echo "$r2" | python3 -c " |
| 128 | +import sys,json |
| 129 | +t=json.load(sys.stdin)['tokenResponse'] |
| 130 | +print(t['access_token'], t['refresh_token'], t['expires_in']) |
| 131 | +" 2>/dev/null) |
| 132 | + METLIFE_TOKEN_EXPIRY=$((now + expires_in)) |
| 133 | + |
| 134 | + if [ -z "$METLIFE_BEARER_TOKEN" ] || [ "$METLIFE_BEARER_TOKEN" = "None" ]; then |
| 135 | + echo "ERROR: No access token in response" >&2; return 1 |
| 136 | + fi |
| 137 | + |
| 138 | + save_tokens |
| 139 | + echo "Login successful. Token expires in ${expires_in}s (~$((expires_in/60))m)." >&2 |
| 140 | +} |
| 141 | + |
| 142 | +# ── Token refresh ──────────────────────────────────────────────────────────── |
| 143 | + |
| 144 | +refresh_token() { |
| 145 | + load_tokens |
| 146 | + [ -z "$METLIFE_REFRESH_TOKEN" ] && { echo "ERROR: No refresh token. Login required." >&2; return 1; } |
| 147 | + |
| 148 | + local sid; sid=$(_session_id) |
| 149 | + echo "Refreshing token..." >&2 |
| 150 | + local r |
| 151 | + r=$(curl -s "$APIM_BASE/authentication/v2/refreshToken_v2" \ |
| 152 | + -H 'content-type: application/json' \ |
| 153 | + -H "channel-id: $CHANNEL_ID" \ |
| 154 | + -H 'is-ping-token: true' \ |
| 155 | + -H "ocp-apim-subscription-key: $APIM_KEY" \ |
| 156 | + -H "origin: $ORIGIN" -H "referer: $REFERER" \ |
| 157 | + -H "session-id: $sid" -H "transaction-id: $sid" \ |
| 158 | + -H "x-app-version: $APP_VERSION" \ |
| 159 | + -H 'cache-control: no-cache, no-store' \ |
| 160 | + --data-raw "\"$METLIFE_REFRESH_TOKEN\"" 2>/dev/null) |
| 161 | + |
| 162 | + local new_tok new_ref exp_in |
| 163 | + read -r new_tok new_ref exp_in < <(echo "$r" | python3 -c " |
| 164 | +import sys,json |
| 165 | +d=json.load(sys.stdin) |
| 166 | +print(d.get('access_token',''), d.get('refresh_token',''), d.get('expires_in',899)) |
| 167 | +" 2>/dev/null) |
| 168 | + |
| 169 | + if [ -n "$new_tok" ] && [ "$new_tok" != "None" ]; then |
| 170 | + local now; now=$(date +%s) |
| 171 | + METLIFE_BEARER_TOKEN="$new_tok" |
| 172 | + [ -n "$new_ref" ] && [ "$new_ref" != "None" ] && METLIFE_REFRESH_TOKEN="$new_ref" |
| 173 | + METLIFE_TOKEN_EXPIRY=$((now + exp_in)) |
| 174 | + save_tokens |
| 175 | + echo "Token refreshed. Expires in ${exp_in}s (~$((exp_in/60))m)." >&2 |
| 176 | + return 0 |
| 177 | + fi |
| 178 | + echo "ERROR: Refresh failed. Response: $r" >&2 |
| 179 | + return 1 |
| 180 | +} |
| 181 | + |
| 182 | +# ── Auth gate ──────────────────────────────────────────────────────────────── |
| 183 | +# Call before any API operation. Returns 0 if ready, 1 if login needed. |
| 184 | +# On failure, prints AUTH_NEEDED to stdout so the caller can detect it. |
| 185 | + |
| 186 | +ensure_auth() { |
| 187 | + load_tokens |
| 188 | + if token_is_expired; then |
| 189 | + if [ -n "$METLIFE_REFRESH_TOKEN" ]; then |
| 190 | + refresh_token && return 0 |
| 191 | + fi |
| 192 | + echo "AUTH_NEEDED" |
| 193 | + return 1 |
| 194 | + fi |
| 195 | + return 0 |
| 196 | +} |
| 197 | + |
| 198 | +# ── Response validation ────────────────────────────────────────────────────── |
| 199 | + |
| 200 | +_is_error() { |
| 201 | + echo "$1" | grep -q '"httpCode":"401"\|"Unauthorized"\|"Token is not active"\|"httpCode":"403"' |
| 202 | +} |
| 203 | + |
| 204 | +is_cacheable() { |
| 205 | + [ -n "$1" ] && ! _is_error "$1" |
| 206 | +} |
| 207 | + |
| 208 | +# ── API wrappers ───────────────────────────────────────────────────────────── |
| 209 | + |
| 210 | +# IBM API Connect gateway: claims, customer details, payments, search |
| 211 | +# Usage: metlife_ibm "URL" [extra curl args...] |
| 212 | +metlife_ibm() { |
| 213 | + load_tokens |
| 214 | + local url="$1"; shift |
| 215 | + local r |
| 216 | + r=$(curl -s "$url" \ |
| 217 | + -H "authorization: Bearer $METLIFE_BEARER_TOKEN" \ |
| 218 | + -H "x-ibm-client-id: $IBM_CLIENT_ID" \ |
| 219 | + -H "x-session-id: $METLIFE_SESSION_ID" \ |
| 220 | + -H "x-app-version: $APP_VERSION" \ |
| 221 | + -H "app-version: $APP_VERSION" \ |
| 222 | + -H 'cache-control: no-cache, no-store' \ |
| 223 | + -H "origin: $ORIGIN" -H "referer: $REFERER" \ |
| 224 | + -H 'accept: */*' \ |
| 225 | + "$@") |
| 226 | + |
| 227 | + if _is_error "$r"; then |
| 228 | + echo "Got 401, refreshing..." >&2 |
| 229 | + if refresh_token; then |
| 230 | + r=$(curl -s "$url" \ |
| 231 | + -H "authorization: Bearer $METLIFE_BEARER_TOKEN" \ |
| 232 | + -H "x-ibm-client-id: $IBM_CLIENT_ID" \ |
| 233 | + -H "x-session-id: $METLIFE_SESSION_ID" \ |
| 234 | + -H "x-app-version: $APP_VERSION" \ |
| 235 | + -H "app-version: $APP_VERSION" \ |
| 236 | + -H 'cache-control: no-cache, no-store' \ |
| 237 | + -H "origin: $ORIGIN" -H "referer: $REFERER" \ |
| 238 | + -H 'accept: */*' \ |
| 239 | + "$@") |
| 240 | + fi |
| 241 | + fi |
| 242 | + echo "$r" |
| 243 | +} |
| 244 | + |
| 245 | +# Azure APIM gateway: pets, policies, rewards |
| 246 | +# Usage: metlife_apim "URL" [extra curl args...] |
| 247 | +metlife_apim() { |
| 248 | + load_tokens |
| 249 | + local url="$1"; shift |
| 250 | + local r |
| 251 | + r=$(curl -s "$url" \ |
| 252 | + -H "authorization: Bearer $METLIFE_BEARER_TOKEN" \ |
| 253 | + -H "ocp-apim-subscription-key: $APIM_KEY" \ |
| 254 | + -H "channel-id: $CHANNEL_ID" \ |
| 255 | + -H 'is-ping-token: true' \ |
| 256 | + -H "session-id: $METLIFE_SESSION_ID" \ |
| 257 | + -H "transaction-id: $METLIFE_SESSION_ID" \ |
| 258 | + -H "x-app-version: $APP_VERSION" \ |
| 259 | + -H 'cache-control: no-cache, no-store' \ |
| 260 | + -H "origin: $ORIGIN" -H "referer: $REFERER" \ |
| 261 | + -H 'accept: */*' \ |
| 262 | + "$@") |
| 263 | + |
| 264 | + if _is_error "$r"; then |
| 265 | + echo "Got 401, refreshing..." >&2 |
| 266 | + if refresh_token; then |
| 267 | + r=$(curl -s "$url" \ |
| 268 | + -H "authorization: Bearer $METLIFE_BEARER_TOKEN" \ |
| 269 | + -H "ocp-apim-subscription-key: $APIM_KEY" \ |
| 270 | + -H "channel-id: $CHANNEL_ID" \ |
| 271 | + -H 'is-ping-token: true' \ |
| 272 | + -H "session-id: $METLIFE_SESSION_ID" \ |
| 273 | + -H "transaction-id: $METLIFE_SESSION_ID" \ |
| 274 | + -H "x-app-version: $APP_VERSION" \ |
| 275 | + -H 'cache-control: no-cache, no-store' \ |
| 276 | + -H "origin: $ORIGIN" -H "referer: $REFERER" \ |
| 277 | + -H 'accept: */*' \ |
| 278 | + "$@") |
| 279 | + fi |
| 280 | + fi |
| 281 | + echo "$r" |
| 282 | +} |
| 283 | + |
| 284 | +# Auto-routing wrapper: picks the right gateway based on URL |
| 285 | +metlife_api() { |
| 286 | + local url="$1" |
| 287 | + if [[ "$url" == *"apis.metlife.com"* ]]; then |
| 288 | + metlife_apim "$@" |
| 289 | + else |
| 290 | + metlife_ibm "$@" |
| 291 | + fi |
| 292 | +} |
| 293 | + |
| 294 | +# ── Cache cleanup ──────────────────────────────────────────────────────────── |
| 295 | + |
| 296 | +# Purge any cached JSON files that contain error responses |
| 297 | +purge_bad_cache() { |
| 298 | + local count=0 |
| 299 | + while IFS= read -r -d '' f; do |
| 300 | + if grep -q '"httpCode":"401"\|"Token is not active"\|"Unauthorized"' "$f" 2>/dev/null; then |
| 301 | + rm "$f" |
| 302 | + ((count++)) |
| 303 | + fi |
| 304 | + done < <(find "$METLIFE_CACHE_DIR" -name '*.json' -print0 2>/dev/null) |
| 305 | + [ "$count" -gt 0 ] && echo "Purged $count cached error responses." >&2 |
| 306 | +} |
| 307 | + |
| 308 | +# Full reset: clear all cached data and tokens |
| 309 | +metlife_reset() { |
| 310 | + rm -f "$TOKEN_FILE" |
| 311 | + rm -rf "$METLIFE_CACHE_DIR/claims" "$METLIFE_CACHE_DIR/documents" "$METLIFE_CACHE_DIR/policies" |
| 312 | + mkdir -p "$METLIFE_CACHE_DIR/claims" "$METLIFE_CACHE_DIR/documents" "$METLIFE_CACHE_DIR/policies" |
| 313 | + echo "Cache and tokens cleared." >&2 |
| 314 | +} |
| 315 | + |
| 316 | +# ── Auto-init ──────────────────────────────────────────────────────────────── |
| 317 | + |
| 318 | +load_tokens |
| 319 | +purge_bad_cache |
0 commit comments