diff --git a/CHANGELOG.md b/CHANGELOG.md index 984cefe..10dd7b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.8] - 2026-04-14 + +### Fixed + +- **Authentication**: Updated email/password authentication to match Skylight's current web OAuth flow. The server now follows the browser login sequence (`/oauth/authorize` -> `/auth/session` -> `/oauth/token`) and uses the returned bearer token for API requests. + +### Changed + +- Centralized shared API constants, including the Skylight API version header +- Updated auth-related docs and error guidance to reflect OAuth-based login +- Added automated tests for the OAuth login flow and kept live smoke validation against the real API + ## [1.1.7] - 2025-12-30 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 173dd14..91c4891 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ npm run generate:types # Generate TypeScript types from OpenAPI spec **Key files**: - `config.ts` - Zod-validated env config supporting two auth methods - `api/client.ts` - HTTP client with Bearer/Basic auth, auto-login, subscription status tracking -- `api/auth.ts` - Login endpoint for email/password authentication +- `api/auth.ts` - Browser-style OAuth login flow for email/password authentication - `api/generated-types.ts` - Auto-generated types from OpenAPI spec - `utils/dates.ts` - Parses "today", "tomorrow", day names, YYYY-MM-DD @@ -41,18 +41,18 @@ npm run generate:types # Generate TypeScript types from OpenAPI spec Two methods supported (validated via Zod refinement in `config.ts`): -1. **Email/Password** (recommended): Set `SKYLIGHT_EMAIL` and `SKYLIGHT_PASSWORD`. Server auto-logs in via POST /api/sessions and uses `Basic base64(userId:token)` format for subsequent requests. +1. **Email/Password** (recommended): Set `SKYLIGHT_EMAIL` and `SKYLIGHT_PASSWORD`. Server reproduces the Skylight web OAuth flow (`/oauth/authorize` -> login form -> `/auth/session` -> `/oauth/token`) and then uses the returned bearer token for API requests. 2. **Manual Token**: Set `SKYLIGHT_TOKEN` and optionally `SKYLIGHT_AUTH_TYPE` (bearer/basic). Both require `SKYLIGHT_FRAME_ID` (household identifier from API URLs like `/api/frames/{frameId}/chores`). -**Note**: The Skylight API uses Basic auth with the format `Basic base64(userId:token)`, not Bearer tokens. +**Note**: Email/password auth now resolves to a bearer token. Manual token auth still supports either `bearer` or `basic`. ## Plus Subscription Some features require a Skylight Plus subscription. The server detects subscription status from the login response (`subscription_status: "plus"`). Plus-only tools are not registered for non-Plus users. -**Plus-only domains**: Rewards, Meals, Photos +**Plus-only domains**: Rewards, Meals, Photos. Subscription status is inferred after login from live API access. ## MCP Tools (35+ total) @@ -103,4 +103,4 @@ The release workflow (`.github/workflows/release.yml`) will: ## API Quirks - **Calendar date_max is exclusive**: When querying calendar events, `date_max` is treated as exclusive. The code adds 1 day to include events on the end date. -- **Auth format**: API expects `Basic base64(userId:token)`, not Bearer tokens. +- **Auth format**: Managed email/password auth now uses OAuth and bearer tokens. Manual token auth may still use bearer or basic depending on the captured token. diff --git a/README.md b/README.md index eeb22a9..98237dd 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ The MCP server supports two authentication methods: ### Option 1: Email/Password (Recommended) -Use your Skylight account credentials. The server will automatically log in and manage tokens. +Use your Skylight account credentials. The server automatically follows Skylight's web OAuth login flow and manages the returned bearer token for you. ```env SKYLIGHT_EMAIL=your_email@example.com @@ -100,7 +100,7 @@ SKYLIGHT_FRAME_ID=your_frame_id ### Option 2: Manual Token (Legacy) -Capture a token from the Skylight app using a proxy tool. +Capture a bearer or basic token from Skylight traffic using a proxy tool. ```env SKYLIGHT_TOKEN=your_token_here @@ -123,11 +123,17 @@ You still need to find your frame ID (the household identifier): |----------|----------|-------------| | `SKYLIGHT_EMAIL` | Option 1 | Your Skylight account email | | `SKYLIGHT_PASSWORD` | Option 1 | Your Skylight account password | -| `SKYLIGHT_TOKEN` | Option 2 | Your API token (if not using email/password) | +| `SKYLIGHT_TOKEN` | Option 2 | Your captured API token (if not using email/password) | | `SKYLIGHT_AUTH_TYPE` | No | `bearer` (default) or `basic` (for manual token) | | `SKYLIGHT_FRAME_ID` | Yes | Your household frame ID | | `SKYLIGHT_TIMEZONE` | No | Default timezone (default: `America/New_York`) | +### Auth Notes + +- Email/password auth no longer uses the legacy `/api/sessions` token flow. +- The server now reproduces the Skylight web login flow and exchanges the resulting authorization code for a bearer token. +- Manual token auth still works if you prefer to provide a captured token directly. + ### Example .env file: ```env diff --git a/package-lock.json b/package-lock.json index 78c0b08..30d319f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@skylight/mcp-server", - "version": "1.1.6", + "name": "@eaglebyte/skylight-mcp", + "version": "1.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@skylight/mcp-server", - "version": "1.1.6", + "name": "@eaglebyte/skylight-mcp", + "version": "1.1.8", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.11.0", diff --git a/package.json b/package.json index eb5f259..7ee689b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eaglebyte/skylight-mcp", - "version": "1.1.7", + "version": "1.1.8", "description": "MCP server for Skylight Calendar API - enables agentic interactions for calendar, chores, lists, and family management", "type": "module", "main": "dist/index.js", diff --git a/src/api/auth.ts b/src/api/auth.ts index 830ee6d..9516d3c 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,74 +1,417 @@ /** * Skylight Authentication - * Handles login via email/password to obtain API token + * Handles the browser-style OAuth login flow used by Skylight web. */ -const BASE_URL = "https://app.ourskylight.com"; - -export interface LoginResponse { - data: { - id: string; - type: "authenticated_user"; - attributes: { - email: string; - token: string; - subscription_status: string; - }; - }; - meta?: { - password_reset?: boolean; - }; +import { createHash, randomUUID } from "node:crypto"; +import { SKYLIGHT_API_VERSION, SKYLIGHT_BASE_URL, SKYLIGHT_WEB_APP_URL } from "./constants.js"; + +const REDIRECT_URI = `${SKYLIGHT_WEB_APP_URL}/welcome`; +const CLIENT_ID = "skylight-mobile"; +const SCOPE = "everything"; +const WEB_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0"; + +export interface OAuthTokenResponse { + access_token?: string; + token?: string; + token_type?: string; + scope?: string; + refresh_token?: string; + expires_in?: number; + created_at?: number; + [key: string]: unknown; } export interface AuthResult { - userId: string; email: string; token: string; - subscriptionStatus: string; + subscriptionStatus: string | null; } -/** - * Login to Skylight with email and password - * Returns the authentication token and user info - */ -export async function login(email: string, password: string): Promise { - console.error(`[auth] Attempting login for ${email}...`); +class CookieJar { + private cookies = new Map(); + + setFromResponse(response: Response): void { + const headerValues = + typeof response.headers.getSetCookie === "function" + ? response.headers.getSetCookie().flatMap((headerValue) => splitSetCookieHeader(headerValue)) + : splitSetCookieHeader(response.headers.get("set-cookie")); + + for (const header of headerValues) { + const [cookiePair] = header.split(";", 1); + const separatorIndex = cookiePair.indexOf("="); + if (separatorIndex <= 0) { + continue; + } + + const name = cookiePair.slice(0, separatorIndex).trim(); + const value = cookiePair.slice(separatorIndex + 1).trim(); + if (name && value) { + this.cookies.set(name, value); + } + } + } + + toHeader(): string | undefined { + if (this.cookies.size === 0) { + return undefined; + } + + return Array.from(this.cookies.entries()) + .map(([name, value]) => `${name}=${value}`) + .join("; "); + } +} + +function splitSetCookieHeader(headerValue: string | null): string[] { + if (!headerValue) { + return []; + } + + const cookies: string[] = []; + let current = ""; + let inExpiresAttribute = false; + + for (let index = 0; index < headerValue.length; index += 1) { + const char = headerValue[index]; + const remainingHeader = headerValue.slice(index).toLowerCase(); + + if (!inExpiresAttribute && remainingHeader.startsWith("expires=")) { + inExpiresAttribute = true; + } + + if (char === "," && !inExpiresAttribute) { + cookies.push(current.trim()); + current = ""; + continue; + } + + if (inExpiresAttribute && char === ";") { + inExpiresAttribute = false; + } + + current += char; + } + + if (current.trim()) { + cookies.push(current.trim()); + } + + return cookies; +} + +function createPkceVerifier(): string { + return randomUUID().replace(/-/g, "") + randomUUID().replace(/-/g, ""); +} + +function createPkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +function createState(): string { + return randomUUID().replace(/-/g, "").slice(0, 10); +} + +function buildAuthorizeUrl(params: { state: string; codeChallenge: string; prompt?: "login" }): string { + const url = new URL("/oauth/authorize", SKYLIGHT_BASE_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("code_challenge", params.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("state", params.state); + + if (params.prompt) { + url.searchParams.set("prompt", params.prompt); + } + + return url.toString(); +} + +function parseAuthenticityToken(html: string): string { + const match = html.match(/name=["']authenticity_token["'][^>]*value=["']([^"']+)["']/i); + if (!match) { + throw new Error("Could not find authenticity token in Skylight login form"); + } + return match[1]; +} + +function parseAuthorizationCode(location: string, expectedState: string): string { + const url = new URL(location); + const state = url.searchParams.get("state"); + const code = url.searchParams.get("code"); + + if (!code) { + throw new Error("OAuth redirect did not include an authorization code"); + } + + if (state !== expectedState) { + throw new Error("OAuth state mismatch during Skylight login"); + } + + return code; +} + +function getDeviceMetadata(): Record { + const platform = + process.platform === "darwin" ? "Macintosh" : + process.platform === "win32" ? "Windows" : + process.platform === "linux" ? "Linux" : + process.platform; + + return { + skylight_api_client_device_fingerprint: randomUUID(), + skylight_api_client_device_platform: "web", + skylight_api_client_device_name: "unknown", + skylight_api_client_device_os_version: "unknown", + skylight_api_client_device_app_version: "unknown", + skylight_api_client_device_hardware: platform, + }; +} - const response = await fetch(`${BASE_URL}/api/sessions`, { +async function fetchWithCookies( + url: string, + init: RequestInit, + cookieJar: CookieJar +): Promise { + const headers = new Headers(init.headers); + const cookieHeader = cookieJar.toHeader(); + + if (cookieHeader) { + headers.set("Cookie", cookieHeader); + } + + const response = await fetch(url, { + ...init, + headers, + redirect: "manual", + }); + + cookieJar.setFromResponse(response); + return response; +} + +async function beginOAuthFlow(state: string, codeChallenge: string, cookieJar: CookieJar): Promise { + const response = await fetchWithCookies( + buildAuthorizeUrl({ state, codeChallenge, prompt: "login" }), + { + method: "GET", + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "User-Agent": WEB_USER_AGENT, + Referer: `${SKYLIGHT_WEB_APP_URL}/`, + }, + }, + cookieJar + ); + + const location = response.headers.get("location"); + if (response.status !== 302 || !location) { + response.body?.cancel(); + throw new Error(`Unexpected authorize response: HTTP ${response.status}`); + } + + response.body?.cancel(); + return new URL(location).toString(); +} + +async function loadLoginForm(loginUrl: string, cookieJar: CookieJar): Promise { + const response = await fetchWithCookies( + loginUrl, + { + method: "GET", + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "User-Agent": WEB_USER_AGENT, + Referer: `${SKYLIGHT_WEB_APP_URL}/`, + }, + }, + cookieJar + ); + + if (!response.ok) { + response.body?.cancel(); + throw new Error(`Failed to load Skylight login form: HTTP ${response.status}`); + } + + return response.text(); +} + +async function submitLoginForm( + email: string, + password: string, + authenticityToken: string, + cookieJar: CookieJar +): Promise { + const body = new URLSearchParams({ + authenticity_token: authenticityToken, + email, + password, + }); + + const response = await fetchWithCookies( + `${SKYLIGHT_BASE_URL}/auth/session`, + { + method: "POST", + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Content-Type": "application/x-www-form-urlencoded", + Origin: SKYLIGHT_BASE_URL, + Referer: `${SKYLIGHT_BASE_URL}/auth/session/new`, + "User-Agent": WEB_USER_AGENT, + }, + body, + }, + cookieJar + ); + + const location = response.headers.get("location"); + if (response.status === 302 && location) { + response.body?.cancel(); + return new URL(location, SKYLIGHT_BASE_URL).toString(); + } + + const errorBody = await response.text(); + if (response.status === 401 || response.status === 422) { + throw new Error("Invalid email or password. Please check your SKYLIGHT_EMAIL and SKYLIGHT_PASSWORD environment variables."); + } + + throw new Error(`Skylight login form submission failed: HTTP ${response.status}${errorBody ? ` - ${errorBody.slice(0, 200)}` : ""}`); +} + +async function authorizeAuthenticatedSession(authorizeUrl: string, cookieJar: CookieJar): Promise { + const response = await fetchWithCookies( + authorizeUrl, + { + method: "GET", + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + Referer: `${SKYLIGHT_BASE_URL}/auth/session/new`, + "User-Agent": WEB_USER_AGENT, + }, + }, + cookieJar + ); + + const location = response.headers.get("location"); + if (response.status !== 302 || !location) { + response.body?.cancel(); + throw new Error(`Unexpected post-login authorize response: HTTP ${response.status}`); + } + + response.body?.cancel(); + return location; +} + +async function exchangeAuthorizationCode(code: string, codeVerifier: string): Promise { + const tokenBody = new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + scope: SCOPE, + redirect_uri: REDIRECT_URI, + code, + code_verifier: codeVerifier, + ...getDeviceMetadata(), + }); + + const response = await fetch(`${SKYLIGHT_BASE_URL}/oauth/token`, { method: "POST", headers: { - "Content-Type": "application/json", - Accept: "application/json", + Accept: "application/json, text/javascript; q=0.01", + "Content-Type": "application/x-www-form-urlencoded", + Origin: SKYLIGHT_WEB_APP_URL, + Referer: `${SKYLIGHT_WEB_APP_URL}/`, + "User-Agent": WEB_USER_AGENT, }, - body: JSON.stringify({ email, password }), + body: tokenBody, }); - console.error(`[auth] Login response status: ${response.status}`); - if (!response.ok) { - let errorBody = ""; - try { - errorBody = await response.text(); - console.error(`[auth] Login error response: ${errorBody}`); - } catch { - // ignore + const errorBody = await response.text(); + throw new Error(`OAuth token exchange failed: HTTP ${response.status}${errorBody ? ` - ${errorBody.slice(0, 200)}` : ""}`); + } + + const data = (await response.json()) as OAuthTokenResponse; + const token = data.access_token ?? data.token; + if (!token) { + throw new Error("OAuth token exchange did not return an access token"); + } + + return token; +} + +async function detectSubscriptionStatus(token: string): Promise { + try { + const response = await fetch(`${SKYLIGHT_BASE_URL}/api/plus_access`, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + Origin: SKYLIGHT_WEB_APP_URL, + Referer: `${SKYLIGHT_WEB_APP_URL}/`, + "Skylight-Api-Version": SKYLIGHT_API_VERSION, + "User-Agent": "SkylightMobile (web)", + }, + }); + + if (response.status === 401 || response.status === 403 || response.status === 404) { + response.body?.cancel(); + return "free"; + } + + if (!response.ok) { + response.body?.cancel(); + return null; } - if (response.status === 401) { - throw new Error(`Invalid email or password. Please check your SKYLIGHT_EMAIL and SKYLIGHT_PASSWORD environment variables.`); + const rawBody = await response.text(); + if (!rawBody.trim()) { + return "plus"; + } + + const body = JSON.parse(rawBody) as unknown; + const text = JSON.stringify(body).toLowerCase(); + if (text.includes("\"subscription_status\":\"plus\"") || text.includes("\"plus\":true") || text.includes("\"has_access\":true")) { + return "plus"; + } + if (text.includes("\"subscription_status\":\"free\"") || text.includes("\"plus\":false") || text.includes("\"has_access\":false")) { + return "free"; } - throw new Error(`Login failed: HTTP ${response.status}${errorBody ? ` - ${errorBody}` : ""}`); + + return "plus"; + } catch { + return null; } +} + +/** + * Login to Skylight with email and password. + * Replays the same browser OAuth flow observed from Skylight web. + */ +export async function login(email: string, password: string): Promise { + console.error("[auth] Starting OAuth login."); + + const cookieJar = new CookieJar(); + const state = createState(); + const codeVerifier = createPkceVerifier(); + const codeChallenge = createPkceChallenge(codeVerifier); - const data = (await response.json()) as LoginResponse; + const loginUrl = await beginOAuthFlow(state, codeChallenge, cookieJar); + const loginHtml = await loadLoginForm(loginUrl, cookieJar); + const authenticityToken = parseAuthenticityToken(loginHtml); + const authorizeUrl = await submitLoginForm(email, password, authenticityToken, cookieJar); + const callbackLocation = await authorizeAuthenticatedSession(authorizeUrl, cookieJar); + const authorizationCode = parseAuthorizationCode(callbackLocation, state); + const token = await exchangeAuthorizationCode(authorizationCode, codeVerifier); + const subscriptionStatus = await detectSubscriptionStatus(token); - console.error(`[auth] Login successful, token prefix: ${data.data.attributes.token.substring(0, 10)}...`); + console.error("[auth] OAuth login successful."); return { - userId: data.data.id, - email: data.data.attributes.email, - token: data.data.attributes.token, - subscriptionStatus: data.data.attributes.subscription_status, + email, + token, + subscriptionStatus, }; } diff --git a/src/api/client.ts b/src/api/client.ts index 8ed0b3b..8f27409 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,6 @@ import { getConfig, usesEmailAuth, type Config } from "../config.js"; import { login } from "./auth.js"; +import { SKYLIGHT_API_VERSION, SKYLIGHT_BASE_URL } from "./constants.js"; import { AuthenticationError, NotFoundError, @@ -7,8 +8,6 @@ import { SkylightError, } from "../utils/errors.js"; -const BASE_URL = "https://app.ourskylight.com"; - /** * Skylight subscription status types */ @@ -27,8 +26,7 @@ export interface RequestOptions { export class SkylightClient { private config: Config; private resolvedToken: string | null = null; - private resolvedUserId: string | null = null; - private loginPromise: Promise<{ token: string; userId: string }> | null = null; + private loginPromise: Promise<{ token: string }> | null = null; private subscriptionStatus: SubscriptionStatus = null; constructor(config?: Config) { @@ -39,21 +37,20 @@ export class SkylightClient { * Get the authentication credentials * If using email/password auth, will login first */ - private async getCredentials(): Promise<{ token: string; userId: string | null }> { + private async getCredentials(): Promise<{ token: string }> { // If we already have a resolved token, use it if (this.resolvedToken) { - return { token: this.resolvedToken, userId: this.resolvedUserId }; + return { token: this.resolvedToken }; } // If using token-based auth, use the configured token if (!usesEmailAuth(this.config)) { - return { token: this.config.token!, userId: null }; + return { token: this.config.token! }; } // If already logging in, wait for that to complete if (this.loginPromise) { - const result = await this.loginPromise; - return { token: result.token, userId: result.userId }; + return this.loginPromise; } // Login with email/password @@ -61,7 +58,6 @@ export class SkylightClient { try { const result = await this.loginPromise; this.resolvedToken = result.token; - this.resolvedUserId = result.userId; return result; } finally { this.loginPromise = null; @@ -71,7 +67,7 @@ export class SkylightClient { /** * Perform login and return token and userId */ - private async performLogin(): Promise<{ token: string; userId: string }> { + private async performLogin(): Promise<{ token: string }> { const { email, password } = this.config; if (!email || !password) { throw new AuthenticationError("Email and password are required for login"); @@ -80,22 +76,20 @@ export class SkylightClient { console.error("Logging in to Skylight..."); const result = await login(email, password); this.subscriptionStatus = result.subscriptionStatus as SubscriptionStatus; - console.error(`Logged in as ${result.email} (${result.subscriptionStatus})`); - return { token: result.token, userId: result.userId }; + console.error(`Logged in as ${result.email}${result.subscriptionStatus ? ` (${result.subscriptionStatus})` : ""}`); + return { token: result.token }; } /** * Build the Authorization header - * For email/password auth: Basic base64(userId:token) - * For manual token auth: Bearer or Basic based on config + * Email/password auth now resolves to an OAuth bearer token. + * Manual token auth still respects the configured auth type. */ private async getAuthHeader(): Promise { - const { token, userId } = await this.getCredentials(); + const { token } = await this.getCredentials(); - // If using email/password auth, use Basic auth with userId:token - if (usesEmailAuth(this.config) && userId) { - const credentials = Buffer.from(`${userId}:${token}`).toString("base64"); - return `Basic ${credentials}`; + if (usesEmailAuth(this.config)) { + return `Bearer ${token}`; } // For manual token config, respect the authType setting @@ -109,7 +103,7 @@ export class SkylightClient { * Build URL with query parameters */ private buildUrl(endpoint: string, params?: Record): string { - const url = new URL(endpoint, BASE_URL); + const url = new URL(endpoint, SKYLIGHT_BASE_URL); if (params) { for (const [key, value] of Object.entries(params)) { @@ -131,7 +125,6 @@ export class SkylightClient { if (status === 401) { // Clear cached credentials on auth failure this.resolvedToken = null; - this.resolvedUserId = null; console.error(`[client] 401 Unauthorized for ${url}`); if (usesEmailAuth(this.config)) { @@ -181,6 +174,8 @@ export class SkylightClient { const headers: Record = { Authorization: await this.getAuthHeader(), Accept: "application/json", + "User-Agent": "SkylightMobile (web)", + "Skylight-Api-Version": SKYLIGHT_API_VERSION, }; if (body) { @@ -200,7 +195,6 @@ export class SkylightClient { if (response.status === 401 && usesEmailAuth(this.config) && !isRetry) { console.error("[client] Got 401, attempting re-login..."); this.resolvedToken = null; - this.resolvedUserId = null; return this.request(endpoint, options, true); } await this.handleResponseError(response, url); diff --git a/src/api/constants.ts b/src/api/constants.ts new file mode 100644 index 0000000..06c4f64 --- /dev/null +++ b/src/api/constants.ts @@ -0,0 +1,3 @@ +export const SKYLIGHT_BASE_URL = "https://app.ourskylight.com"; +export const SKYLIGHT_WEB_APP_URL = "https://ourskylight.com"; +export const SKYLIGHT_API_VERSION = "2026-03-01"; diff --git a/src/config.ts b/src/config.ts index 133b4fe..0632bc7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,8 @@ import { z } from "zod"; // Config schema supports two auth methods: -// 1. Email/password (preferred) - will login and get token automatically -// 2. Token-based (legacy) - for manual token capture +// 1. Email/password (preferred) - replays the Skylight web OAuth flow automatically +// 2. Token-based (legacy/manual) - for captured bearer/basic tokens const ConfigSchema = z .object({ // Email/password auth (preferred) @@ -94,7 +94,7 @@ export function getConfig(): Config { } /** - * Check if config uses email/password auth + * Check if config uses managed OAuth login via email/password */ export function usesEmailAuth(config: Config): boolean { return !!(config.email && config.password); diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 60f1e6d..21e5728 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -77,9 +77,9 @@ export function formatErrorForMcp(error: unknown): string { return `Authentication Error: ${error.message} Your Skylight token may have expired. To fix this: -1. Open the Skylight app on your device -2. Capture fresh API traffic using a proxy tool -3. Update your SKYLIGHT_TOKEN environment variable +1. If using email/password auth, retry with valid SKYLIGHT_EMAIL and SKYLIGHT_PASSWORD +2. If using a manual token, capture fresh API traffic using a proxy tool +3. Update your SKYLIGHT_TOKEN environment variable if you are using manual token auth See the auth documentation for detailed steps.`; } diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..1267a2e --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { login } from "../src/api/auth.js"; + +function textResponse(status: number, body: string, headers: HeadersInit = {}): Response { + return new Response(body, { + status, + headers, + }); +} + +function jsonResponse(status: number, body: unknown, headers: HeadersInit = {}): Response { + const responseHeaders = new Headers(headers); + responseHeaders.set("content-type", "application/json"); + + return new Response(JSON.stringify(body), { + status, + headers: responseHeaders, + }); +} + +describe("auth", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("completes the OAuth login flow and returns a bearer token", async () => { + let capturedState = ""; + let capturedCodeVerifier = ""; + + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.startsWith("https://app.ourskylight.com/oauth/authorize?") && url.includes("prompt=login")) { + const params = new URL(url).searchParams; + capturedState = params.get("state") ?? ""; + return textResponse(302, "", { + location: "https://app.ourskylight.com/auth/session/new", + "set-cookie": + "_skylight_cloud_session=abc123; path=/; secure; httponly, skylight_notice=dismissed; Expires=Wed, 15 Apr 2026 00:00:00 GMT; Path=/; Secure", + }); + } + + if (url === "https://app.ourskylight.com/auth/session/new") { + const headers = new Headers(init?.headers); + expect(headers.get("cookie")).toContain("_skylight_cloud_session=abc123"); + expect(headers.get("cookie")).toContain("skylight_notice=dismissed"); + return textResponse( + 200, + '
' + ); + } + + if (url === "https://app.ourskylight.com/auth/session") { + const headers = new Headers(init?.headers); + expect(headers.get("cookie")).toContain("_skylight_cloud_session=abc123"); + expect(headers.get("cookie")).toContain("skylight_notice=dismissed"); + const body = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? "")); + expect(body.get("authenticity_token")).toBe("form-token-123"); + expect(body.get("email")).toBe("user@example.com"); + expect(body.get("password")).toBe("secret"); + + return textResponse(302, "", { + location: "https://app.ourskylight.com/oauth/authorize?client_id=skylight-mobile", + }); + } + + if (url === "https://app.ourskylight.com/oauth/authorize?client_id=skylight-mobile") { + return textResponse(302, "", { + location: `https://ourskylight.com/welcome?code=oauth-code-123&state=${capturedState}`, + }); + } + + if (url === "https://app.ourskylight.com/oauth/token") { + const body = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? "")); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("client_id")).toBe("skylight-mobile"); + expect(body.get("scope")).toBe("everything"); + expect(body.get("code")).toBe("oauth-code-123"); + capturedCodeVerifier = body.get("code_verifier") ?? ""; + expect(capturedCodeVerifier.length).toBeGreaterThan(20); + + return jsonResponse(200, { + access_token: "bearer-token-123", + token_type: "Bearer", + }); + } + + if (url === "https://app.ourskylight.com/api/plus_access") { + expect(init?.headers).toBeDefined(); + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer bearer-token-123"); + expect(headers.get("skylight-api-version")).toBe("2026-03-01"); + return textResponse(200, ""); + } + + throw new Error(`Unexpected fetch call: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const result = await login("user@example.com", "secret"); + + expect(result).toEqual({ + email: "user@example.com", + token: "bearer-token-123", + subscriptionStatus: "plus", + }); + expect(fetchMock).toHaveBeenCalledTimes(6); + }); + + it("throws a helpful error for invalid credentials", async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.startsWith("https://app.ourskylight.com/oauth/authorize?") && url.includes("prompt=login")) { + return textResponse(302, "", { + location: "https://app.ourskylight.com/auth/session/new", + }); + } + + if (url === "https://app.ourskylight.com/auth/session/new") { + return textResponse( + 200, + '
' + ); + } + + if (url === "https://app.ourskylight.com/auth/session") { + return textResponse(422, "invalid credentials"); + } + + throw new Error(`Unexpected fetch call: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + await expect(login("user@example.com", "wrong-password")).rejects.toThrow( + "Invalid email or password" + ); + }); +});