diff --git a/.env.example b/.env.example index 5a484b4..e07a9b5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,30 @@ -# replace with your own values +# Replace with your own values. PORT=3000 -CORS_ORIGIN=https://shellular.dev \ No newline at end of file +HOST=0.0.0.0 +NODE_ENV=dev +CORS_ORIGIN=https://shellular.dev +CONTACT_EMAIL=team@shellular.dev +WS_TOKEN_SECRET=replace-with-at-least-32-random-characters + +# OAuth +# Public API origin used when building provider callback URLs. +AUTH_PUBLIC_BASE_URL=https://api.shellular.dev +# Deep link used after OAuth completes. The mobile app listens for this URL. +AUTH_APP_CALLBACK_URL=shellular://auth-callback + +# Google OAuth +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# GitHub OAuth +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Sign in with Apple +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= + +# PostHog +POSTHOG_KEY= diff --git a/.gitignore b/.gitignore index fff61fc..e703ee2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ mermaid/ *.local -credentials.json \ No newline at end of file +credentials.json +*.p8 \ No newline at end of file diff --git a/docs/oauth-flow.md b/docs/oauth-flow.md new file mode 100644 index 0000000..89d6d57 --- /dev/null +++ b/docs/oauth-flow.md @@ -0,0 +1,133 @@ +# Shellular OAuth Flow + +Shellular requires app users to sign in before onboarding, host pairing, or app WebSocket access. The CLI host registration flow remains unauthenticated for now. + +## Configuration + +Copy `.env.example` to `.env` and configure the provider credentials you want to enable. A provider is shown by `GET /auth/providers` only when all required values for that provider are present. + +Required base settings: + +- `NODE_ENV`: `dev` or `prod`. +- `AUTH_PUBLIC_BASE_URL`: public server origin used for OAuth callback URLs, for example `https://api.shellular.dev`. +- `AUTH_APP_CALLBACK_URL`: app deep link used after OAuth completes, usually `shellular://auth-callback`. +- `CORS_ORIGIN`: app/web origin allowed to call the API. + +Provider settings: + +- Google: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`. +- GitHub: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`. +- Apple: `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, plus the `server/apple_key.p8` file. + +For Apple, the private key is the PKCS#8 `.p8` file from Apple Developer. Place it at `server/apple_key.p8`; this file is ignored by git and read directly by the server. + +Provider redirect URLs to register: + +- Google: `{AUTH_PUBLIC_BASE_URL}/auth/oauth/google/callback` +- GitHub: `{AUTH_PUBLIC_BASE_URL}/auth/oauth/github/callback` +- Apple: `{AUTH_PUBLIC_BASE_URL}/auth/oauth/apple/callback` + +## Login Sequence + +```mermaid +sequenceDiagram + participant App + participant Server + participant Provider as OAuth Provider + participant SQLite + + App->>Server: GET /auth/providers + Server-->>App: Enabled providers + App->>Server: POST /auth/oauth/:provider/start + Server->>SQLite: Store state hash and PKCE verifier if needed + Server-->>App: authorizationUrl + App->>Provider: Open authorizationUrl + Provider->>Server: Callback with code and state + Server->>SQLite: Consume state + Server->>Provider: Exchange code for provider tokens/profile + Server->>SQLite: Upsert user and linked OAuth account + Server->>SQLite: Store one-time exchange code hash + Server-->>App: Redirect app callback with code + App->>Server: POST /auth/exchange + Server->>SQLite: Consume exchange code + Server->>SQLite: Create session, access token hash, refresh token hash + Server-->>App: User, access token, refresh token +``` + +The app receives only a short-lived exchange code through the callback URL. Access and refresh tokens are returned only through the direct `/auth/exchange` API call. + +Android debug builds send `shellular-dev://auth-callback` when starting OAuth so they can coexist with the production app without Android showing an app chooser. The server stores the requested app callback URL with the OAuth state and falls back to `AUTH_APP_CALLBACK_URL` for older clients or invalid callbacks. + +Browser builds cannot receive custom URL schemes, so they send a same-origin callback URL with `shellularAuthCallback=1`, for example `https://app.shellular.dev/?shellularAuthCallback=1`. For browser sign-in, the server completes the OAuth callback itself, creates the Shellular session, sets HttpOnly Secure SameSite=None cookies on the API origin, and redirects the popup back to the app callback. The original browser tab then refreshes `/auth/me` with `credentials: include`. The popup callback still posts a lightweight completion signal and closes, but it no longer carries access or refresh tokens. + +## Token Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Unauthenticated + Unauthenticated --> OAuthBrowser: User taps provider + OAuthBrowser --> ExchangeCode: Provider redirects to app callback + ExchangeCode --> Authenticated: /auth/exchange succeeds + Authenticated --> Refreshing: Access token near expiry + Refreshing --> Authenticated: /auth/refresh rotates refresh token + Refreshing --> Unauthenticated: Refresh token invalid or idle > 30 days + Authenticated --> Unauthenticated: Logout revokes session +``` + +Access tokens expire after 15 minutes. Refresh tokens rotate on every refresh and expire after 30 days of inactivity. Server-side token storage uses SHA-256 hashes of opaque random tokens, so raw tokens are never stored in SQLite. + +## WebSocket Enforcement + +```mermaid +flowchart TD + A[App wants to connect to /app WebSocket] --> B[App refreshes access token if needed] + B --> C[App posts client info to /auth/ws-app-token with Authorization bearer access token] + C --> D{Server validates access token, client info, host availability, and known client identity} + D -- Invalid or expired --> E[Reject token request] + D -- Valid --> F[Server returns short-lived wsToken] + F --> G[App opens /app?wsToken=...] + G --> H{Server validates wsToken} + H -- Invalid or expired --> I[Reject upgrade with 403] + H -- Valid --> J[Request CLI approval] + J --> K[Join relay session] +``` + +Only the app WebSocket path requires OAuth. The `/cli` WebSocket path and `/host/register` flow are intentionally unchanged. + +## Account Linking + +During normal sign-in, OAuth identities are still resolved by verified email: + +- If an OAuth provider returns an existing verified email, the provider account is attached to that Shellular user. +- If the email is new, the server creates a new Shellular user. +- If no verified email is returned, login fails with an app-facing error. + +The first OAuth account linked to a Shellular user is the primary account. The primary account anchors the user's Shellular email, display name, and avatar, and it cannot be unlinked. + +```mermaid +sequenceDiagram + participant App + participant Server + participant Provider as OAuth Provider + participant SQLite + + App->>Server: POST /auth/oauth/:provider/link/start with access token + Server->>SQLite: Reject if provider already linked + Server->>SQLite: Store link state hash and current user id + Server-->>App: authorizationUrl + App->>Provider: Open authorizationUrl + Provider->>Server: Callback with code and state + Server->>SQLite: Consume link state + Server->>Provider: Exchange code for verified profile + Server->>SQLite: Store one-time link code hash + Server-->>App: Redirect app callback with linkCode + App->>Server: POST /auth/oauth/link/exchange with access token + Server->>SQLite: Consume link code and attach provider + Server-->>App: Updated user with linkedAccounts +``` + +Secondary provider emails may differ from the primary email because the user proves control of the provider during OAuth. A secondary OAuth identity cannot be linked if it is already attached to another Shellular user. Users may unlink secondary providers with `DELETE /auth/oauth/accounts/:provider`; unlinking affects future sign-in options only and does not revoke the current Shellular session. + +## Logout And Revocation + +`POST /auth/logout` revokes the active session and all access/refresh tokens for that session. The app also removes the native refresh token from secure storage and returns to the login gate. diff --git a/package.json b/package.json index 7fee952..2317800 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,22 @@ "format": "pnpx @biomejs/biome check --write src", "start": "NODE_ENV=prod tsx src/main.ts", "notices": "tsx scripts/notices.ts", - "pm2-start": "pm2 start --name api.shellular.dev npx -- tsx src/main.ts" + "pm2-start": "pm2 start --name api.shellular.dev pnpm run start" }, "keywords": [], "author": "", "license": "AGPL-3.0-only", "type": "commonjs", "dependencies": { - "@shellular/protocol": "^0.0.22", + "@oslojs/encoding": "^1.1.0", + "@shellular/protocol": "^0.0.26", + "arctic": "^3.7.0", "better-sqlite3": "^12.10.0", "dotenv": "^17.4.1", "express": "^5.2.1", "express-rate-limit": "^8.5.2", "nanoid": "^5.1.7", + "posthog-node": "^5.40.0", "ws": "^8.21.0", "zod": "^4.3.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fa3e3c..a02cd2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: dependencies: + '@oslojs/encoding': + specifier: ^1.1.0 + version: 1.1.0 '@shellular/protocol': - specifier: ^0.0.22 - version: 0.0.22 + specifier: ^0.0.26 + version: 0.0.26 + arctic: + specifier: ^3.7.0 + version: 3.7.0 better-sqlite3: specifier: ^12.10.0 version: 12.10.0 @@ -26,6 +32,9 @@ importers: nanoid: specifier: ^5.1.7 version: 5.1.7 + posthog-node: + specifier: ^5.40.0 + version: 5.40.0 ws: specifier: ^8.21.0 version: 8.21.0(bufferutil@4.1.0) @@ -276,8 +285,32 @@ packages: cpu: [x64] os: [win32] - '@shellular/protocol@0.0.22': - resolution: {integrity: sha512-pAm0EhF+q/0wbABDUU8rn0fPnOWZ3Hhyx9SOzjfx8sww7UZTlNzoztdIRTlQ2+mDUOxB4RwZ/6k7gjxY6tTGnA==} + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + + '@oslojs/crypto@1.0.1': + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + + '@oslojs/encoding@0.4.1': + resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oslojs/jwt@0.2.0': + resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} + + '@posthog/core@1.40.1': + resolution: {integrity: sha512-jXuMtZCwA7AMpYlo1wjm6GlC58YBlz/ZxpDIsF9hroUfqYoPPDmQbsQb4iiZAS43+o/kwloxdKJIlE0IiwQ5HQ==} + + '@posthog/types@1.393.0': + resolution: {integrity: sha512-vzWeEJZ7ERQhFRoQYaP5jzN1JvIu46UJyHXsuv+dTGW2r3sMgREOhNxXLZjmFHwZ8/FOHQoyqqQmXTCXZSfMSg==} + + '@shellular/protocol@0.0.26': + resolution: {integrity: sha512-97HZ6fVVVr1BK8uxVQpzHRkDRL4uo2naThYoMDghJPmnMwgSzCElC0e8WzLS+7VW/2x+zlzSFyi67B/1PIkhuA==} '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -319,6 +352,9 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + arctic@3.7.0: + resolution: {integrity: sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -606,6 +642,15 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + posthog-node@5.40.0: + resolution: {integrity: sha512-DrLfHuauO0W6qruF80iqr5JdmLysef74XzOB4eh36oRLRhxCySLraTqsi2Pj161LZnp9/JNdRDxwT8ei8VK2YA==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -880,7 +925,32 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@shellular/protocol@0.0.22': + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/binary@1.0.0': {} + + '@oslojs/crypto@1.0.1': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/encoding@0.4.1': {} + + '@oslojs/encoding@1.1.0': {} + + '@oslojs/jwt@0.2.0': + dependencies: + '@oslojs/encoding': 0.4.1 + + '@posthog/core@1.40.1': + dependencies: + '@posthog/types': 1.393.0 + + '@posthog/types@1.393.0': {} + + '@shellular/protocol@0.0.26': dependencies: '@agentclientprotocol/sdk': 0.26.0(zod@4.3.6) zod: 4.3.6 @@ -939,6 +1009,12 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + arctic@3.7.0: + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@oslojs/jwt': 0.2.0 + base64-js@1.5.1: {} better-sqlite3@12.10.0: @@ -1240,6 +1316,10 @@ snapshots: path-to-regexp@8.4.2: {} + posthog-node@5.40.0: + dependencies: + '@posthog/core': 1.40.1 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 diff --git a/readme.md b/readme.md index 1e03030..44a5569 100644 --- a/readme.md +++ b/readme.md @@ -15,6 +15,12 @@ WebSocket relay server for [Shellular](https://shellular.dev) — relays message pnpm install ``` +### Environment + +Create a local `.env` from `.env.example`. The server requires `WS_TOKEN_SECRET` with at least 32 characters for signing short-lived app WebSocket tickets. + +OAuth provider settings live in `.env`. Apple Sign in with Apple uses `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, and `APPLE_KEY_ID`, and reads the private key from the ignored `apple_key.p8` file in the server directory. + ### Run (watch mode) ```bash @@ -29,6 +35,22 @@ Server listens on `0.0.0.0:3000` by default. pnpm start ``` +## OAuth Login + +See [docs/oauth-flow.md](docs/oauth-flow.md) for provider setup, token lifecycle, and the app/WebSocket authentication flow. + +## App WebSocket Authentication + +The app no longer sends access tokens or device metadata in the `/app` WebSocket URL. Instead: + +1. The app refreshes its access token. +2. The app sends `POST /auth/ws-app-token` with `Authorization: Bearer ` and client metadata in the JSON body. +3. The server validates the user token, `ClientInfoSchema`, host existence, and known-client identity. +4. The server returns a signed app WebSocket ticket that expires after 30 seconds. +5. The app opens `/app?wsToken=`. + +The `/cli` WebSocket and `/host/register` flow are unchanged. + ## App Notices Short, dismiss-once messages shown as a popup on the app home screen (e.g. maintenance heads-ups). Notices are served from a plain JSON file so they can be pushed to already-installed apps without an app update. diff --git a/src/auth/crypto.ts b/src/auth/crypto.ts new file mode 100644 index 0000000..f921fdc --- /dev/null +++ b/src/auth/crypto.ts @@ -0,0 +1,9 @@ +import { createHash, randomBytes } from "node:crypto"; + +export function createToken(prefix: string): string { + return `${prefix}_${randomBytes(32).toString("base64url")}`; +} + +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} diff --git a/src/auth/providers.ts b/src/auth/providers.ts new file mode 100644 index 0000000..75bf8ee --- /dev/null +++ b/src/auth/providers.ts @@ -0,0 +1,311 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import * as arctic from "arctic"; + +import { env } from "@/env"; +import { BadRequestError } from "@/error/http"; +import { + type AuthProvider, + consumeLoginState, + type LoginState, + type ProviderProfile, + saveLoginState, +} from "./store"; + +const PROVIDERS = ["google", "github", "apple"] as const; +const APPLE_PRIVATE_KEY_FILE = "apple_key.p8"; + +type ProviderStatus = { + id: AuthProvider; + enabled: boolean; +}; + +type AppleUser = { + name?: { + firstName?: string; + lastName?: string; + }; + email?: string; +}; + +export function listProviders(): ProviderStatus[] { + return PROVIDERS.map((id) => ({ id, enabled: isProviderEnabled(id) })); +} + +export function isProviderEnabled(provider: AuthProvider): boolean { + switch (provider) { + case "google": + return Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET); + case "github": + return Boolean(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET); + case "apple": + return Boolean( + env.APPLE_CLIENT_ID && + env.APPLE_TEAM_ID && + env.APPLE_KEY_ID && + hasApplePrivateKey(), + ); + } +} + +export function assertProvider(value: string): AuthProvider { + if (PROVIDERS.includes(value as AuthProvider)) { + return value as AuthProvider; + } + throw new BadRequestError("Unsupported OAuth provider."); +} + +export function createAuthorizationUrl( + provider: AuthProvider, + options: { callbackUrl?: string } = {}, +): string { + return createOAuthAuthorizationUrl(provider, options); +} + +export function createLinkAuthorizationUrl( + provider: AuthProvider, + userId: string, + options: { callbackUrl?: string } = {}, +): string { + return createOAuthAuthorizationUrl(provider, { + ...options, + purpose: "link", + userId, + }); +} + +function createOAuthAuthorizationUrl( + provider: AuthProvider, + options: { + callbackUrl?: string; + purpose?: LoginState["purpose"]; + userId?: string; + } = {}, +): string { + ensureProviderEnabled(provider); + const state = arctic.generateState(); + + if (provider === "google") { + const codeVerifier = arctic.generateCodeVerifier(); + saveLoginState(state, provider, { + codeVerifier, + callbackUrl: options.callbackUrl, + purpose: options.purpose, + userId: options.userId, + }); + return getGoogle() + .createAuthorizationURL(state, codeVerifier, [ + "openid", + "profile", + "email", + ]) + .toString(); + } + + saveLoginState(state, provider, { + callbackUrl: options.callbackUrl, + purpose: options.purpose, + userId: options.userId, + }); + if (provider === "github") { + return getGitHub() + .createAuthorizationURL(state, ["read:user", "user:email"]) + .toString(); + } + + const url = getApple().createAuthorizationURL(state, ["name", "email"]); + url.searchParams.set("response_mode", "form_post"); + return url.toString(); +} + +type ProviderCallbackResult = { + profile: ProviderProfile; + loginState: LoginState; +}; + +export async function getProfileFromCallback( + provider: AuthProvider, + code: string, + state: string, + appleUser?: string, +): Promise { + ensureProviderEnabled(provider); + const loginState = consumeLoginState(state, provider); + + if (provider === "google") { + if (!loginState.codeVerifier) { + throw new BadRequestError("Invalid sign-in request."); + } + const tokens = await getGoogle().validateAuthorizationCode( + code, + loginState.codeVerifier, + ); + const claims = arctic.decodeIdToken(tokens.idToken()) as Record< + string, + unknown + >; + return { + loginState, + profile: { + provider, + providerAccountId: String(claims.sub ?? ""), + email: String(claims.email ?? ""), + emailVerified: claims.email_verified === true, + name: asString(claims.name), + avatarUrl: asString(claims.picture), + }, + }; + } + + if (provider === "github") { + const tokens = await getGitHub().validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const [userResp, emailResp] = await Promise.all([ + fetch("https://api.github.com/user", { + headers: githubHeaders(accessToken), + }), + fetch("https://api.github.com/user/emails", { + headers: githubHeaders(accessToken), + }), + ]); + + if (!userResp.ok || !emailResp.ok) { + throw new BadRequestError("GitHub did not return account details."); + } + + const user = (await userResp.json()) as Record; + const emails = (await emailResp.json()) as Array>; + const primary = + emails.find( + (email) => email.primary === true && email.verified === true, + ) ?? emails.find((email) => email.verified === true); + + return { + loginState, + profile: { + provider, + providerAccountId: String(user.id ?? ""), + email: String(primary?.email ?? ""), + emailVerified: Boolean(primary), + name: asString(user.name) ?? asString(user.login), + avatarUrl: asString(user.avatar_url), + }, + }; + } + + const tokens = await getApple().validateAuthorizationCode(code); + const claims = arctic.decodeIdToken(tokens.idToken()) as Record< + string, + unknown + >; + const parsedAppleUser = parseAppleUser(appleUser); + const name = [ + parsedAppleUser?.name?.firstName, + parsedAppleUser?.name?.lastName, + ] + .filter(Boolean) + .join(" "); + + return { + loginState, + profile: { + provider, + providerAccountId: String(claims.sub ?? ""), + email: String(claims.email ?? parsedAppleUser?.email ?? ""), + emailVerified: + claims.email_verified === true || claims.email_verified === "true", + name: name || null, + avatarUrl: null, + }, + }; +} + +function getGoogle() { + return new arctic.Google( + required(env.GOOGLE_CLIENT_ID, "GOOGLE_CLIENT_ID"), + required(env.GOOGLE_CLIENT_SECRET, "GOOGLE_CLIENT_SECRET"), + callbackUrl("google"), + ); +} + +function getGitHub() { + return new arctic.GitHub( + required(env.GITHUB_CLIENT_ID, "GITHUB_CLIENT_ID"), + required(env.GITHUB_CLIENT_SECRET, "GITHUB_CLIENT_SECRET"), + callbackUrl("github"), + ); +} + +function getApple() { + return new arctic.Apple( + required(env.APPLE_CLIENT_ID, "APPLE_CLIENT_ID"), + required(env.APPLE_TEAM_ID, "APPLE_TEAM_ID"), + required(env.APPLE_KEY_ID, "APPLE_KEY_ID"), + parseApplePrivateKey(getApplePrivateKey()), + callbackUrl("apple"), + ); +} + +function callbackUrl(provider: AuthProvider): string { + return new URL( + `/auth/oauth/${provider}/callback`, + env.AUTH_PUBLIC_BASE_URL, + ).toString(); +} + +function ensureProviderEnabled(provider: AuthProvider): void { + if (!isProviderEnabled(provider)) { + throw new BadRequestError("This sign-in provider is not configured."); + } +} + +function required(value: string | undefined, name: string): string { + if (!value) throw new Error(`Missing ${name}`); + return value; +} + +function getApplePrivateKey(): string { + const path = applePrivateKeyFilePath(); + if (!existsSync(path)) { + throw new Error(`Apple private key file not found: ${path}`); + } + return readFileSync(path, "utf8"); +} + +function hasApplePrivateKey(): boolean { + return existsSync(applePrivateKeyFilePath()); +} + +function applePrivateKeyFilePath(): string { + return resolve(__dirname, "..", "..", APPLE_PRIVATE_KEY_FILE); +} + +function parseApplePrivateKey(value: string): Uint8Array { + const normalized = value.replace(/\\n/g, "\n"); + const base64 = normalized + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace(/\s/g, ""); + return new Uint8Array(Buffer.from(base64, "base64")); +} + +function parseAppleUser(value?: string): AppleUser | null { + if (!value) return null; + try { + return JSON.parse(value) as AppleUser; + } catch { + return null; + } +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value ? value : null; +} + +function githubHeaders(accessToken: string): HeadersInit { + return { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${accessToken}`, + "User-Agent": "Shellular", + }; +} diff --git a/src/auth/store.ts b/src/auth/store.ts new file mode 100644 index 0000000..7760b20 --- /dev/null +++ b/src/auth/store.ts @@ -0,0 +1,591 @@ +import { nanoid } from "nanoid"; +import { db } from "@/db"; +import { BadRequestError, ConflictError, ForbiddenError } from "@/error/http"; +import { createToken, hashToken } from "./crypto"; + +export type AuthProvider = "google" | "github" | "apple"; + +export type AuthLinkedAccount = { + provider: AuthProvider; + email: string; + isPrimary: boolean; + linkedAt: number; +}; + +export type AuthUser = { + id: string; + email: string; + name: string | null; + avatarUrl: string | null; + linkedAccounts: AuthLinkedAccount[]; +}; + +export type LoginState = { + provider: AuthProvider; + purpose: "signin" | "link"; + userId: string | null; + codeVerifier: string | null; + callbackUrl: string | null; + expiresAt: number; +}; + +export type TokenPair = { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + user: AuthUser; +}; + +export type ProviderProfile = { + provider: AuthProvider; + providerAccountId: string; + email: string; + emailVerified: boolean; + name?: string | null; + avatarUrl?: string | null; +}; + +const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000; +const REFRESH_TOKEN_IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const LOGIN_STATE_TTL_MS = 10 * 60 * 1000; +const EXCHANGE_CODE_TTL_MS = 5 * 60 * 1000; +const LINK_CODE_TTL_MS = 5 * 60 * 1000; + +export function saveLoginState( + state: string, + provider: AuthProvider, + options: { + codeVerifier?: string; + callbackUrl?: string; + purpose?: LoginState["purpose"]; + userId?: string; + } = {}, +) { + const now = Date.now(); + db.prepare( + "INSERT INTO oauth_login_states (stateHash, provider, purpose, userId, codeVerifier, callbackUrl, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ).run( + hashToken(state), + provider, + options.purpose ?? "signin", + options.userId ?? null, + options.codeVerifier ?? null, + options.callbackUrl ?? null, + now, + now + LOGIN_STATE_TTL_MS, + ); +} + +export function consumeLoginState( + state: string, + provider: AuthProvider, +): LoginState { + const stateHash = hashToken(state); + const row = db + .prepare( + "SELECT provider, purpose, userId, codeVerifier, callbackUrl, expiresAt FROM oauth_login_states WHERE stateHash = ?", + ) + .get(stateHash) as LoginState | undefined; + + db.prepare("DELETE FROM oauth_login_states WHERE stateHash = ?").run( + stateHash, + ); + + if (!row || row.provider !== provider || row.expiresAt < Date.now()) { + throw new BadRequestError( + "This sign-in request expired. Please try again.", + ); + } + + return row; +} + +export function getLoginStateCallbackUrl( + state: string, + provider: AuthProvider, +): string | null { + const row = db + .prepare( + "SELECT provider, callbackUrl, expiresAt FROM oauth_login_states WHERE stateHash = ?", + ) + .get(hashToken(state)) as + | Pick + | undefined; + + if (!row || row.provider !== provider || row.expiresAt < Date.now()) { + return null; + } + + return row.callbackUrl; +} + +export function assertProviderCanBeLinked( + userId: string, + provider: AuthProvider, +): void { + const existing = db + .prepare( + "SELECT provider FROM oauth_accounts WHERE userId = ? AND provider = ?", + ) + .get(userId, provider) as { provider: AuthProvider } | undefined; + if (existing) { + throw new ConflictError("This provider is already linked."); + } +} + +export function upsertUserFromProvider(profile: ProviderProfile): AuthUser { + const email = verifiedProfileEmail(profile); + + const now = Date.now(); + const account = db + .prepare( + "SELECT userId, isPrimary FROM oauth_accounts WHERE provider = ? AND providerAccountId = ?", + ) + .get(profile.provider, profile.providerAccountId) as + | { userId: string; isPrimary: number } + | undefined; + + let user = account ? getUserById(account.userId) : getUserByEmail(email); + if (!user) { + const id = `u_${nanoid(18)}`; + db.prepare( + "INSERT INTO users (id, email, name, avatarUrl, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)", + ).run(id, email, profile.name ?? null, profile.avatarUrl ?? null, now, now); + user = getUserById(id); + } else if (!account || account.isPrimary) { + db.prepare( + "UPDATE users SET name = COALESCE(?, name), avatarUrl = COALESCE(?, avatarUrl), updatedAt = ? WHERE id = ?", + ).run(profile.name ?? null, profile.avatarUrl ?? null, now, user.id); + user = getUserById(user.id); + } + + if (!user) { + throw new Error("Failed to create authenticated user"); + } + + if (!account) { + const existingProviderForUser = db + .prepare( + "SELECT providerAccountId FROM oauth_accounts WHERE userId = ? AND provider = ?", + ) + .get(user.id, profile.provider) as + | { providerAccountId: string } + | undefined; + if (existingProviderForUser) { + throw new ConflictError("This provider is already linked."); + } + } + + const isPrimary = account?.isPrimary ?? (hasPrimaryAccount(user.id) ? 0 : 1); + db.prepare( + `INSERT INTO oauth_accounts (provider, providerAccountId, userId, email, isPrimary, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, providerAccountId) + DO UPDATE SET userId = excluded.userId, email = excluded.email, updatedAt = excluded.updatedAt`, + ).run( + profile.provider, + profile.providerAccountId, + user.id, + email, + isPrimary, + now, + now, + ); + + return getUserById(user.id) ?? user; +} + +export function createLinkCode( + userId: string, + profile: ProviderProfile, +): string { + const email = verifiedProfileEmail(profile); + const code = createToken("lnk"); + const now = Date.now(); + db.prepare( + `INSERT INTO auth_link_codes + (codeHash, userId, provider, providerAccountId, email, createdAt, expiresAt) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + hashToken(code), + userId, + profile.provider, + profile.providerAccountId, + email, + now, + now + LINK_CODE_TTL_MS, + ); + return code; +} + +export function exchangeLinkCodeForAccount( + code: string, + userId: string, +): AuthUser { + const codeHash = hashToken(code); + const row = db + .prepare( + `SELECT userId, provider, providerAccountId, email, expiresAt, usedAt + FROM auth_link_codes + WHERE codeHash = ?`, + ) + .get(codeHash) as + | { + userId: string; + provider: AuthProvider; + providerAccountId: string; + email: string; + expiresAt: number; + usedAt: number | null; + } + | undefined; + + if (!row || row.usedAt || row.expiresAt < Date.now()) { + throw new BadRequestError( + "This account-linking request expired. Please try again.", + ); + } + + if (row.userId !== userId) { + throw new ForbiddenError("This account-linking request is not yours."); + } + + db.prepare("UPDATE auth_link_codes SET usedAt = ? WHERE codeHash = ?").run( + Date.now(), + codeHash, + ); + + return linkProviderAccount(userId, { + provider: row.provider, + providerAccountId: row.providerAccountId, + email: row.email, + emailVerified: true, + }); +} + +export function unlinkProviderAccount( + userId: string, + provider: AuthProvider, +): AuthUser { + const row = db + .prepare( + "SELECT isPrimary FROM oauth_accounts WHERE userId = ? AND provider = ?", + ) + .get(userId, provider) as { isPrimary: number } | undefined; + if (!row) { + throw new BadRequestError("This provider is not linked."); + } + if (row.isPrimary) { + throw new ForbiddenError("Your primary account cannot be unlinked."); + } + + db.prepare( + "DELETE FROM oauth_accounts WHERE userId = ? AND provider = ?", + ).run(userId, provider); + const user = getUserById(userId); + if (!user) throw new ForbiddenError("Your account is no longer available."); + return user; +} + +export function linkProviderAccount( + userId: string, + profile: ProviderProfile, +): AuthUser { + const email = verifiedProfileEmail(profile); + const user = getUserById(userId); + if (!user) throw new ForbiddenError("Your account is no longer available."); + + const linkedIdentity = db + .prepare( + "SELECT userId FROM oauth_accounts WHERE provider = ? AND providerAccountId = ?", + ) + .get(profile.provider, profile.providerAccountId) as + | { userId: string } + | undefined; + if (linkedIdentity?.userId === userId) { + throw new ConflictError("This provider is already linked."); + } + if (linkedIdentity) { + throw new ConflictError( + "This OAuth account is already linked to another Shellular user.", + ); + } + + assertProviderCanBeLinked(userId, profile.provider); + const now = Date.now(); + db.prepare( + `INSERT INTO oauth_accounts + (provider, providerAccountId, userId, email, isPrimary, createdAt, updatedAt) + VALUES (?, ?, ?, ?, 0, ?, ?)`, + ).run(profile.provider, profile.providerAccountId, userId, email, now, now); + + const nextUser = getUserById(userId); + if (!nextUser) + throw new ForbiddenError("Your account is no longer available."); + return nextUser; +} + +export function createExchangeCode(userId: string): string { + const code = createToken("exc"); + const now = Date.now(); + db.prepare( + "INSERT INTO auth_exchange_codes (codeHash, userId, createdAt, expiresAt) VALUES (?, ?, ?, ?)", + ).run(hashToken(code), userId, now, now + EXCHANGE_CODE_TTL_MS); + return code; +} + +export function exchangeCodeForTokens(code: string): TokenPair { + const codeHash = hashToken(code); + const row = db + .prepare( + "SELECT userId, expiresAt, usedAt FROM auth_exchange_codes WHERE codeHash = ?", + ) + .get(codeHash) as + | { userId: string; expiresAt: number; usedAt: number | null } + | undefined; + + if (!row || row.usedAt || row.expiresAt < Date.now()) { + throw new BadRequestError( + "This sign-in link expired. Please sign in again.", + ); + } + + db.prepare( + "UPDATE auth_exchange_codes SET usedAt = ? WHERE codeHash = ?", + ).run(Date.now(), codeHash); + + return createSession(row.userId); +} + +export function refreshToken(refreshToken: string): TokenPair { + const now = Date.now(); + const tokenHash = hashToken(refreshToken); + const token = db + .prepare( + `SELECT tokenHash, sessionId, userId, expiresAt, revokedAt + FROM auth_refresh_tokens WHERE tokenHash = ?`, + ) + .get(tokenHash) as + | { + tokenHash: string; + sessionId: string; + userId: string; + expiresAt: number; + revokedAt: number | null; + } + | undefined; + + if (!token || token.revokedAt || token.expiresAt < now) { + throw new ForbiddenError("Your session expired. Please sign in again."); + } + + const session = getLiveSession(token.sessionId); + if (!session) { + throw new ForbiddenError("Your session expired. Please sign in again."); + } + + const user = getUserById(token.userId); + if (!user) { + throw new ForbiddenError("Your account is no longer available."); + } + + const nextRefreshToken = createToken("rfr"); + const nextRefreshHash = hashToken(nextRefreshToken); + db.prepare( + "UPDATE auth_refresh_tokens SET revokedAt = ?, replacedByHash = ? WHERE tokenHash = ?", + ).run(now, nextRefreshHash, tokenHash); + db.prepare( + "INSERT INTO auth_refresh_tokens (tokenHash, sessionId, userId, createdAt, lastUsedAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?)", + ).run( + nextRefreshHash, + session.id, + user.id, + now, + now, + now + REFRESH_TOKEN_IDLE_TTL_MS, + ); + touchSession(session.id, now + REFRESH_TOKEN_IDLE_TTL_MS); + + const access = createAccessToken(session.id, user.id); + return { + ...access, + refreshToken: nextRefreshToken, + refreshTokenExpiresAt: now + REFRESH_TOKEN_IDLE_TTL_MS, + user, + }; +} + +export function validateAccessToken(accessToken: string): AuthUser | null { + const now = Date.now(); + const row = db + .prepare( + `SELECT sessionId, userId, expiresAt, revokedAt + FROM auth_access_tokens WHERE tokenHash = ?`, + ) + .get(hashToken(accessToken)) as + | { + sessionId: string; + userId: string; + expiresAt: number; + revokedAt: number | null; + } + | undefined; + + if ( + !row || + row.revokedAt || + row.expiresAt < now || + !getLiveSession(row.sessionId) + ) { + return null; + } + + touchSession(row.sessionId); + return getUserById(row.userId) ?? null; +} + +export function revokeSessionByRefreshToken(refreshToken: string): void { + const row = db + .prepare("SELECT sessionId FROM auth_refresh_tokens WHERE tokenHash = ?") + .get(hashToken(refreshToken)) as { sessionId: string } | undefined; + if (row) revokeSession(row.sessionId); +} + +export function revokeSessionByAccessToken(accessToken: string): void { + const row = db + .prepare("SELECT sessionId FROM auth_access_tokens WHERE tokenHash = ?") + .get(hashToken(accessToken)) as { sessionId: string } | undefined; + if (row) revokeSession(row.sessionId); +} + +export function createSession(userId: string): TokenPair { + const user = getUserById(userId); + if (!user) { + throw new ForbiddenError("Your account is no longer available."); + } + + const now = Date.now(); + const sessionId = `ses_${nanoid(24)}`; + const refreshTokenValue = createToken("rfr"); + const refreshTokenHash = hashToken(refreshTokenValue); + const refreshExpiresAt = now + REFRESH_TOKEN_IDLE_TTL_MS; + + db.prepare( + "INSERT INTO auth_sessions (id, userId, createdAt, lastSeenAt, expiresAt) VALUES (?, ?, ?, ?, ?)", + ).run(sessionId, userId, now, now, refreshExpiresAt); + db.prepare( + "INSERT INTO auth_refresh_tokens (tokenHash, sessionId, userId, createdAt, lastUsedAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?)", + ).run(refreshTokenHash, sessionId, userId, now, now, refreshExpiresAt); + + const access = createAccessToken(sessionId, userId); + return { + ...access, + refreshToken: refreshTokenValue, + refreshTokenExpiresAt: refreshExpiresAt, + user, + }; +} + +function createAccessToken(sessionId: string, userId: string) { + const accessToken = createToken("acc"); + const now = Date.now(); + const accessTokenExpiresAt = now + ACCESS_TOKEN_TTL_MS; + db.prepare( + "INSERT INTO auth_access_tokens (tokenHash, sessionId, userId, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?)", + ).run(hashToken(accessToken), sessionId, userId, now, accessTokenExpiresAt); + return { accessToken, accessTokenExpiresAt }; +} + +function getLiveSession(sessionId: string) { + const now = Date.now(); + return db + .prepare( + "SELECT id FROM auth_sessions WHERE id = ? AND revokedAt IS NULL AND expiresAt > ?", + ) + .get(sessionId, now) as { id: string } | undefined; +} + +function touchSession(sessionId: string, expiresAt?: number): void { + const now = Date.now(); + db.prepare( + "UPDATE auth_sessions SET lastSeenAt = ?, expiresAt = COALESCE(?, expiresAt) WHERE id = ?", + ).run(now, expiresAt ?? null, sessionId); +} + +function revokeSession(sessionId: string): void { + const now = Date.now(); + db.prepare("UPDATE auth_sessions SET revokedAt = ? WHERE id = ?").run( + now, + sessionId, + ); + db.prepare( + "UPDATE auth_access_tokens SET revokedAt = ? WHERE sessionId = ? AND revokedAt IS NULL", + ).run(now, sessionId); + db.prepare( + "UPDATE auth_refresh_tokens SET revokedAt = ? WHERE sessionId = ? AND revokedAt IS NULL", + ).run(now, sessionId); +} + +function getUserById(id: string): AuthUser | undefined { + const user = db + .prepare("SELECT id, email, name, avatarUrl FROM users WHERE id = ?") + .get(id) as Omit | undefined; + return user ? { ...user, linkedAccounts: getLinkedAccounts(id) } : undefined; +} + +function getUserByEmail(email: string): AuthUser | undefined { + const user = db + .prepare("SELECT id, email, name, avatarUrl FROM users WHERE email = ?") + .get(email) as Omit | undefined; + return user + ? { ...user, linkedAccounts: getLinkedAccounts(user.id) } + : undefined; +} + +function getLinkedAccounts(userId: string): AuthLinkedAccount[] { + const rows = db + .prepare( + `SELECT provider, email, isPrimary, createdAt + FROM oauth_accounts + WHERE userId = ? + ORDER BY isPrimary DESC, createdAt ASC, provider ASC`, + ) + .all(userId) as Array<{ + provider: AuthProvider; + email: string; + isPrimary: number; + createdAt: number; + }>; + return rows.map((row) => ({ + provider: row.provider, + email: row.email, + isPrimary: Boolean(row.isPrimary), + linkedAt: row.createdAt, + })); +} + +function hasPrimaryAccount(userId: string): boolean { + const row = db + .prepare( + "SELECT provider FROM oauth_accounts WHERE userId = ? AND isPrimary = 1 LIMIT 1", + ) + .get(userId) as { provider: AuthProvider } | undefined; + return Boolean(row); +} + +function verifiedProfileEmail(profile: ProviderProfile): string { + if (!profile.emailVerified) { + throw new ForbiddenError( + "Your OAuth provider did not return a verified email address.", + ); + } + + const email = profile.email.trim().toLowerCase(); + if (!email) { + throw new ForbiddenError( + "Your OAuth provider did not return an email address.", + ); + } + return email; +} diff --git a/src/auth/ws-app-ticket.ts b/src/auth/ws-app-ticket.ts new file mode 100644 index 0000000..b1fad3c --- /dev/null +++ b/src/auth/ws-app-ticket.ts @@ -0,0 +1,102 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +import { + type AuthedClientInfo, + AuthedClientInfoSchema, +} from "@shellular/protocol"; +import { z } from "zod"; + +import { env } from "@/env"; + +export const APP_WEBSOCKET_TOKEN_TTL_SECONDS = 30; + +const TOKEN_HEADER = { alg: "HS256", typ: "JWT" }; +// Tickets are not stored or consumed server-side in this pass; replay is limited +// to the short TTL. +const TOKEN_SECRET = env.WS_TOKEN_SECRET; + +// The authenticated account rides along inside `user` (see AuthedClientInfo) +// rather than as a sibling `userId` claim, so the identity the CLI is shown is +// the same one the token was signed for — they cannot drift apart. +const AppWebSocketTokenPayloadSchema = AuthedClientInfoSchema.extend({ + iat: z.number().int(), + exp: z.number().int(), + jti: z.string().min(1), +}); + +export type AppWebSocketTokenPayload = z.infer< + typeof AppWebSocketTokenPayloadSchema +>; + +export function createAppWebSocketToken(clientInfo: AuthedClientInfo): string { + const iat = Math.floor(Date.now() / 1000); + const payload: AppWebSocketTokenPayload = { + ...clientInfo, + iat, + exp: iat + APP_WEBSOCKET_TOKEN_TTL_SECONDS, + jti: randomBytes(16).toString("base64url"), + }; + + const encodedHeader = encodeJson(TOKEN_HEADER); + const encodedPayload = encodeJson(payload); + const signingInput = `${encodedHeader}.${encodedPayload}`; + return `${signingInput}.${sign(signingInput)}`; +} + +export function verifyAppWebSocketToken( + token: string, +): AppWebSocketTokenPayload | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const [encodedHeader, encodedPayload, signature] = parts; + const signingInput = `${encodedHeader}.${encodedPayload}`; + if (!constantTimeEqual(signature, sign(signingInput))) return null; + + const header = decodeJson(encodedHeader); + if ( + !header || + header.alg !== TOKEN_HEADER.alg || + header.typ !== TOKEN_HEADER.typ + ) { + return null; + } + + const payload = decodeJson(encodedPayload); + const parsed = AppWebSocketTokenPayloadSchema.safeParse(payload); + if (!parsed.success) return null; + + const now = Math.floor(Date.now() / 1000); + if (parsed.data.exp <= now) return null; + + return parsed.data; +} + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +function decodeJson(value: string): Record | null { + try { + const decoded = Buffer.from(value, "base64url").toString("utf8"); + const parsed = JSON.parse(decoded); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : null; + } catch { + return null; + } +} + +function sign(value: string): string { + return createHmac("sha256", TOKEN_SECRET).update(value).digest("base64url"); +} + +function constantTimeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ); +} diff --git a/src/config.ts b/src/config.ts index 458b386..25e954a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import { existsSync, mkdirSync } from "node:fs"; import os from "node:os"; import { join } from "node:path"; @@ -11,6 +12,18 @@ export const DB_FILE = join(APP_DIR, DB_FILE_NAME); // relative this file : server/src/config.ts -> server/ export const PROJECT_ROOT_DIR = join(__dirname, ".."); +export const GIT_COMMIT = (() => { + try { + const sha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + const message = execSync("git log -1 --pretty=%s", { + encoding: "utf8", + }).trim(); + return { sha, message }; + } catch { + return { sha: "unknown", message: "unknown" }; + } +})(); + export const initConfig = () => { if (!existsSync(APP_DIR)) { mkdirSync(APP_DIR, { recursive: true }); diff --git a/src/db/init.sql b/src/db/init.sql index fb414f9..0813747 100644 --- a/src/db/init.sql +++ b/src/db/init.sql @@ -24,4 +24,103 @@ CREATE TABLE IF NOT EXISTS clients ( appVersion TEXT NOT NULL, -- static createdAt INTEGER NOT NULL -); \ No newline at end of file +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + avatarUrl TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS oauth_accounts ( + provider TEXT NOT NULL, + providerAccountId TEXT NOT NULL, + userId TEXT NOT NULL, + email TEXT NOT NULL, + isPrimary INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + PRIMARY KEY (provider, providerAccountId), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_oauth_accounts_userId ON oauth_accounts (userId); + +CREATE INDEX IF NOT EXISTS idx_oauth_accounts_email ON oauth_accounts (email); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider ON oauth_accounts (userId, provider); + +CREATE TABLE IF NOT EXISTS oauth_login_states ( + stateHash TEXT PRIMARY KEY, + provider TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'signin', + userId TEXT, + codeVerifier TEXT, + callbackUrl TEXT, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS auth_exchange_codes ( + codeHash TEXT PRIMARY KEY, + userId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + usedAt INTEGER, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS auth_link_codes ( + codeHash TEXT PRIMARY KEY, + userId TEXT NOT NULL, + provider TEXT NOT NULL, + providerAccountId TEXT NOT NULL, + email TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + usedAt INTEGER, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + lastSeenAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + revokedAt INTEGER, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_auth_sessions_userId ON auth_sessions (userId); + +CREATE TABLE IF NOT EXISTS auth_access_tokens ( + tokenHash TEXT PRIMARY KEY, + sessionId TEXT NOT NULL, + userId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + revokedAt INTEGER, + FOREIGN KEY (sessionId) REFERENCES auth_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_auth_access_tokens_sessionId ON auth_access_tokens (sessionId); + +CREATE TABLE IF NOT EXISTS auth_refresh_tokens ( + tokenHash TEXT PRIMARY KEY, + sessionId TEXT NOT NULL, + userId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + lastUsedAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + revokedAt INTEGER, + replacedByHash TEXT, + FOREIGN KEY (sessionId) REFERENCES auth_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_sessionId ON auth_refresh_tokens (sessionId); \ No newline at end of file diff --git a/src/env.ts b/src/env.ts index 62a4a39..258e4d8 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,24 @@ const envSchema = z.object({ CORS_ORIGIN: z.string().min(1).default("*"), NODE_ENV: z.enum(["dev", "prod"]), CONTACT_EMAIL: z.email().default("team@shellular.dev"), + WS_TOKEN_SECRET: z.string().min(32), + POSTHOG_KEY: z.string().min(1).optional(), + POSTHOG_HOST: z.url().default("https://us.i.posthog.com"), + AUTH_PUBLIC_BASE_URL: z.url().default("https://api.shellular.dev"), + AUTH_APP_CALLBACK_URL: z.string().default("shellular://auth-callback"), + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), + GITHUB_CLIENT_ID: z.string().optional(), + GITHUB_CLIENT_SECRET: z.string().optional(), + APPLE_CLIENT_ID: z.string().optional(), + APPLE_TEAM_ID: z.string().optional(), + APPLE_KEY_ID: z.string().optional(), }); export const env = envSchema.parse(process.env); + +// using console.log instead of local logger to ensure we don't import any local modules in this file +const nodeEnvLog = `--- NODE_ENV: ${env.NODE_ENV} ---`; +console.log("-".repeat(nodeEnvLog.length)); +console.log(nodeEnvLog); +console.log("-".repeat(nodeEnvLog.length)); diff --git a/src/main.ts b/src/main.ts index ad4d614..4d03b85 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,19 +1,23 @@ -import { execSync } from "node:child_process"; import { createServer } from "node:http"; import express from "express"; import { z } from "zod"; -import { initConfig } from "@/config"; + +import { GIT_COMMIT, initConfig } from "@/config"; import { env } from "@/env"; import { HttpError } from "@/error/http"; import { logger } from "@/logger"; import cors from "@/middleware/cors"; import { initNotices } from "@/notices"; -import { router as hostRouter } from "@/routes/host"; -import { router as noticesRouter } from "@/routes/notices"; +import { shutdownPostHog, startHostHeartbeatForPosthog } from "@/posthog"; +import authRoutes from "@/routes/auth"; +import hostRoutes from "@/routes/host"; +import noticesRoutes from "@/routes/notices"; +import utilsRoutes from "@/routes/utils"; +import type { RouteModule } from "@/types"; import { printRoutes } from "@/utils/express"; import { initWebSocketRelay } from "@/websocket/index"; -import { getSessionStats } from "@/websocket/sessions"; +import { getActiveHostSessions, getSessionStats } from "@/websocket/sessions"; process.on("uncaughtException", (err) => { logger.error("Uncaught exception:", err); @@ -25,18 +29,6 @@ process.on("unhandledRejection", (reason) => { initConfig(); -const GIT_COMMIT = (() => { - try { - const sha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); - const message = execSync("git log -1 --pretty=%s", { - encoding: "utf8", - }).trim(); - return { sha, message }; - } catch { - return { sha: "unknown", message: "unknown" }; - } -})(); - const app = express(); app.set("trust proxy", 1); // trust first proxy (if behind a proxy like nginx or cloudflare) @@ -44,8 +36,20 @@ app.set("trust proxy", 1); // trust first proxy (if behind a proxy like nginx or app.use(cors); app.use(express.json()); -app.use(hostRouter); -app.use(noticesRouter); +const routes: RouteModule[] = [ + authRoutes, + hostRoutes, + utilsRoutes, + noticesRoutes, +]; + +for (const { prefix, router } of routes) { + if (prefix) { + app.use(prefix, router); + } else { + app.use(router); + } +} app.use((req, res, next) => { const start = Date.now(); @@ -134,9 +138,20 @@ app.use((_req, res) => { const server = createServer(app); initWebSocketRelay(server); initNotices(); +startHostHeartbeatForPosthog(() => + getActiveHostSessions().map((session) => session.hostInfo), +); server.listen(env.PORT, env.HOST, () => { logger.info(`Server is running on port ${env.PORT}`); }); -printRoutes(app); +for (const signal of ["SIGTERM", "SIGINT"] as const) { + process.on(signal, () => { + logger.info(`Received ${signal}, shutting down`); + server.close(); + shutdownPostHog().finally(() => process.exit(0)); + }); +} + +printRoutes(app, routes); diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index c8d5ed0..105c3b9 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -3,11 +3,16 @@ import type { NextFunction, Request, Response } from "express"; import { env } from "@/env"; export default function cors(req: Request, res: Response, next: NextFunction) { - res.header("Access-Control-Allow-Origin", env.CORS_ORIGIN); + const requestOrigin = req.headers.origin; + res.header( + "Access-Control-Allow-Origin", + env.NODE_ENV === "dev" && requestOrigin ? requestOrigin : env.CORS_ORIGIN, + ); + res.header("Access-Control-Allow-Credentials", "true"); res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); res.header( "Access-Control-Allow-Headers", - "Origin, Content-Type, Accept, x-auth-token", + "Origin, Content-Type, Accept, Authorization, x-auth-token", ); res.header("Vary", "Origin"); diff --git a/src/posthog.ts b/src/posthog.ts new file mode 100644 index 0000000..0aa1fa0 --- /dev/null +++ b/src/posthog.ts @@ -0,0 +1,225 @@ +import type { ClientInfo, HostInfo } from "@shellular/protocol"; +import { PostHog } from "posthog-node"; + +import { env } from "@/env"; +import { logger } from "@/logger"; + +/** + * Shared PostHog client for product analytics (DAU, retention, etc.). + * + * We capture from the server rather than the client because + * - server is the only place we have a *proven* userId + * - Server-side capture also can't be blocked or spoofed by the app + * - Also don't have to worry about Posthog's session replay thing for user privacy (ik it can be disabled but still) + * + * Analytics is enabled only when a PostHog key is configured *and* we're in + * prod: the key is optional (self-hosters/forks/local runs can omit it), and + * even with a key set, dev runs must not pollute DAU/retention with our own + * testing. With no key, `posthog` stays null and every capture no-ops. + */ +const analyticsEnabled = + env.POSTHOG_KEY !== undefined && env.NODE_ENV === "prod"; +logger.info( + `🐽 PostHog analytics ${analyticsEnabled ? "enabled 📊" : "disabled"}`, +); + +const client: PostHog | null = + analyticsEnabled && env.POSTHOG_KEY + ? new PostHog(env.POSTHOG_KEY, { host: env.POSTHOG_HOST }) + : null; + +client?.on("error", (err) => { + // Analytics must never take down or interfere with the relay. + logger.warn("PostHog error:", err); +}); + +function capture(event: Parameters[0]): void { + // null client (no key / non-prod) means analytics is off — nothing to do. + if (!client) { + return; + } + + client.capture(event); +} + +/** + * Every analytics event we capture, at a glance. Keyed by side of the relay + * (`app` = mobile/web client, `cli` = host). Use these constants at call sites + * instead of inline strings so event names stay consistent. + */ +const Events = { + app: { + connectionEstablished: "app_connection_established", + legacyConnectionEstablished: "legacy_app_connection_established", + }, + cli: { + connectionEstablished: "cli_connection_established", + hostActive: "cli_host_active", + }, +} as const; + +function appConnectionProperties(clientInfo: ClientInfo, hostPlatform: string) { + return { + // `source` labels which side of the relay the event came from so app and + // CLI analytics are never conflated. + source: "app" as const, + hostId: clientInfo.hostId, + clientId: clientInfo.clientId, + platform: clientInfo.platform, + appVersion: clientInfo.appVersion, + deviceIsEmulator: clientInfo.deviceIsEmulator, + hostPlatform, + }; +} + +export function captureAppConnection( + userId: string, + clientInfo: ClientInfo, + hostPlatform: string, +): void { + capture({ + distinctId: userId, + event: Events.app.connectionEstablished, + properties: { + ...appConnectionProperties(clientInfo, hostPlatform), + authenticated: true, + // Person profile props so users are filterable in PostHog. + $set: { + lastPlatform: clientInfo.platform, + lastAppVersion: clientInfo.appVersion, + }, + }, + }); +} + +/** + * Legacy app connections have no proven userId (old app versions predating the + * login, or forks that stripped login). We key these on the stable + * clientId so a distinct install counts once, and keep them under a separate + * event so they never inflate authenticated DAU/retention. A current appVersion + * arriving here is a signal of a login-stripped rebuild rather than an old app. + */ +export function captureLegacyAppConnection( + clientInfo: ClientInfo, + hostPlatform: string, +): void { + capture({ + distinctId: clientInfo.clientId, + event: Events.app.legacyConnectionEstablished, + properties: { + ...appConnectionProperties(clientInfo, hostPlatform), + authenticated: false, + // Mark the person profile so legacy installs are filterable but never + // silently merged with a real authenticated user. + $set: { isLegacy: true }, + }, + }); +} + +function hostProperties(hostInfo: HostInfo) { + return { + source: "cli" as const, + hostId: hostInfo.id, + // machineId lets you dedupe by physical machine in PostHog even though + // events are keyed on the per-registration hostId. + machineId: hostInfo.machineId, + platform: hostInfo.platform, + cliVersion: hostInfo.cliVersion, + }; +} + +/** Fired once when a host (CLI) establishes its relay connection. */ +export function captureCliConnection(hostInfo: HostInfo): void { + capture({ + distinctId: hostInfo.id, + event: Events.cli.connectionEstablished, + properties: { + ...hostProperties(hostInfo), + $set: { lastCliVersion: hostInfo.cliVersion, isHost: true }, + }, + }); +} + +/** + * Daily-active heartbeat for a persistent host connection. A host can stay + * connected for days off a single `cli_connection_established`, which would + * undercount it as a DAU. Emitting one `cli_host_active` per host per UTC day + * makes long-lived hosts count as active every day they're up. Callers must + * dedupe per day (see the heartbeat loop) so this never fires more than once + * per host per day. + */ +function captureCliHostActive(hostInfo: HostInfo): void { + capture({ + distinctId: hostInfo.id, + event: Events.cli.hostActive, + properties: hostProperties(hostInfo), + }); +} + +const HEARTBEAT_SWEEP_MS = 6 * 60 * 60 * 1000; // every 6 hours + +// Tracks the last UTC day (YYYY-MM-DD) we emitted cli_host_active for a hostId, +// so a persistent host is counted active at most once per day. +const lastHostActiveDay = new Map(); +let heartbeatTimer: NodeJS.Timeout | null = null; + +function utcDay(now = new Date()): string { + return now.toISOString().slice(0, 10); +} + +/** + * Start the daily host-active heartbeat. Sweeps connected hosts every 6 hours + * and emits one `cli_host_active` per host per UTC day so long-lived host + * connections are counted as daily-active on every day they stay up, not just + * their connect day. The sweep interval only governs how soon after the UTC day + * boundary a persistent host is re-counted; the per-day dedup keeps emissions to + * once per host per day regardless. + * + * `getHosts` is injected to avoid a circular import with the sessions module. + */ +export function startHostHeartbeatForPosthog(getHosts: () => HostInfo[]): void { + // No point sweeping hosts to emit events that would no-op at the gate. + if (!analyticsEnabled || heartbeatTimer) { + return; + } + + const sweep = () => { + const day = utcDay(); + for (const hostInfo of getHosts()) { + if (lastHostActiveDay.get(hostInfo.id) === day) { + // if a host has already been counted for the day, then let's skip it + continue; + } + + lastHostActiveDay.set(hostInfo.id, day); + captureCliHostActive(hostInfo); + } + // Drop entries for days that have rolled over so the map can't grow + // unbounded as hosts churn. + for (const [hostId, seenDay] of lastHostActiveDay) { + if (seenDay !== day) { + lastHostActiveDay.delete(hostId); + } + } + }; + + logger.info("Starting PostHog host-active heartbeat sweep"); + heartbeatTimer = setInterval(sweep, HEARTBEAT_SWEEP_MS); + heartbeatTimer.unref?.(); +} + +/** + * Flush any buffered events and stop the client. Call on graceful shutdown so + * batched events aren't lost when the process exits. + */ +export async function shutdownPostHog(): Promise { + if (!client) { + return; + } + + try { + await client.shutdown(); + } catch (err) { + logger.warn("PostHog shutdown failed:", err); + } +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..ef91210 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,544 @@ +import { + type AuthedClientInfo, + type ClientInfoRequest, + ClientInfoRequestSchema, +} from "@shellular/protocol"; +import express, { Router } from "express"; +import { rateLimit } from "express-rate-limit"; +import { z } from "zod"; +import { + assertProvider, + createAuthorizationUrl, + createLinkAuthorizationUrl, + getProfileFromCallback, + listProviders, +} from "@/auth/providers"; +import { + type AuthUser, + assertProviderCanBeLinked, + createExchangeCode, + createLinkCode, + createSession, + exchangeCodeForTokens, + exchangeLinkCodeForAccount, + getLoginStateCallbackUrl, + type LoginState, + linkProviderAccount, + type ProviderProfile, + refreshToken, + revokeSessionByAccessToken, + revokeSessionByRefreshToken, + type TokenPair, + unlinkProviderAccount, + upsertUserFromProvider, + validateAccessToken, +} from "@/auth/store"; +import { + APP_WEBSOCKET_TOKEN_TTL_SECONDS, + createAppWebSocketToken, +} from "@/auth/ws-app-ticket"; +import { getClient, verifyClient } from "@/db/client"; +import { getHost } from "@/db/host"; +import { env } from "@/env"; +import { BadRequestError, ConflictError, ForbiddenError } from "@/error/http"; + +const router = Router(); +const ROUTE_PREFIX = "/auth"; + +export default { + router, + prefix: ROUTE_PREFIX, +}; + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 60, + standardHeaders: false, + legacyHeaders: false, +}); + +const OAuthStartSchema = z.object({ + provider: z.string(), +}); + +const OAuthStartBodySchema = z.object({ + callbackUrl: z.string().optional(), +}); + +const OAuthCallbackSchema = z.object({ + code: z.string().min(1), + state: z.string().min(1), +}); + +const ExchangeSchema = z.object({ + code: z.string().min(1), +}); + +const RefreshSchema = z.object({ + refreshToken: z.string().min(1), +}); + +const AUTH_ACCESS_COOKIE = "__Host-shellular_access"; +const AUTH_REFRESH_COOKIE = "__Host-shellular_refresh"; + +router.get("/providers", (_req, res) => { + res.json({ success: true, data: { providers: listProviders() } }); +}); + +router.post("/oauth/:provider/start", authLimiter, (req, res) => { + const { provider: rawProvider } = OAuthStartSchema.parse(req.params); + const provider = assertProvider(rawProvider); + const authorizationUrl = createAuthorizationUrl(provider, { + callbackUrl: appCallbackUrl(req), + }); + res.json({ success: true, data: { authorizationUrl } }); +}); + +router.post("/oauth/:provider/link/start", authLimiter, (req, res) => { + const user = requireAuthUser(req); + const { provider: rawProvider } = OAuthStartSchema.parse(req.params); + const provider = assertProvider(rawProvider); + assertProviderCanBeLinked(user.id, provider); + const authorizationUrl = createLinkAuthorizationUrl(provider, user.id, { + callbackUrl: appCallbackUrl(req), + }); + res.json({ success: true, data: { authorizationUrl } }); +}); + +router.get("/oauth/:provider/callback", authLimiter, async (req, res) => { + const provider = assertProvider(String(req.params.provider)); + if (provider === "apple") { + throw new BadRequestError("Apple sign-in callback must use POST."); + } + + const parsed = OAuthCallbackSchema.safeParse(req.query); + if (!parsed.success) { + res.redirect(callbackUrl({ error: "invalid_callback" })); + return; + } + + const appCallbackUrl = getLoginStateCallbackUrl(parsed.data.state, provider); + + try { + const result = await getProfileFromCallback( + provider, + parsed.data.code, + parsed.data.state, + ); + if (isBrowserCallbackUrl(result.loginState.callbackUrl)) { + res.redirect( + callbackUrl( + completeBrowserOAuthCallback(res, result), + result.loginState.callbackUrl, + ), + ); + return; + } + res.redirect( + callbackUrl(completeOAuthCallback(result), result.loginState.callbackUrl), + ); + } catch (error) { + res.redirect(callbackUrl({ error: callbackError(error) }, appCallbackUrl)); + } +}); + +router.post( + "/oauth/apple/callback", + authLimiter, + express.urlencoded({ extended: false }), + async (req, res) => { + const parsed = OAuthCallbackSchema.safeParse(req.body); + if (!parsed.success) { + res.redirect(callbackUrl({ error: "invalid_callback" })); + return; + } + + const appCallbackUrl = getLoginStateCallbackUrl(parsed.data.state, "apple"); + + try { + const result = await getProfileFromCallback( + "apple", + parsed.data.code, + parsed.data.state, + typeof req.body.user === "string" ? req.body.user : undefined, + ); + if (isBrowserCallbackUrl(result.loginState.callbackUrl)) { + res.redirect( + callbackUrl( + completeBrowserOAuthCallback(res, result), + result.loginState.callbackUrl, + ), + ); + return; + } + res.redirect( + callbackUrl( + completeOAuthCallback(result), + result.loginState.callbackUrl, + ), + ); + } catch (error) { + res.redirect( + callbackUrl({ error: callbackError(error) }, appCallbackUrl), + ); + } + }, +); + +router.post("/exchange", authLimiter, (req, res) => { + const { code } = ExchangeSchema.parse(req.body); + res.json({ + success: true, + data: tokenPairResponse(req, exchangeCodeForTokens(code)), + }); +}); + +router.post("/oauth/link/exchange", authLimiter, (req, res) => { + const user = requireAuthUser(req); + const { code } = ExchangeSchema.parse(req.body); + res.json({ + success: true, + data: { + user: authUserResponse(req, exchangeLinkCodeForAccount(code, user.id)), + }, + }); +}); + +router.post("/refresh", authLimiter, (req, res) => { + const parsed = RefreshSchema.safeParse(req.body); + const token = parsed.success + ? parsed.data.refreshToken + : getCookie(req, AUTH_REFRESH_COOKIE); + if (!token) + throw new ForbiddenError("Your session expired. Please sign in again."); + const data = refreshToken(token); + if (!parsed.success) { + setAuthCookies(res, data); + res.json({ success: true, data: browserSessionData(req, data) }); + return; + } + res.json({ success: true, data: tokenPairResponse(req, data) }); +}); + +router.get("/me", (req, res) => { + const user = requireAuthUser(req); + res.json({ success: true, data: { user: authUserResponse(req, user) } }); +}); + +router.post("/ws-app-token", (req, res) => { + const user = requireAuthUser(req); + // Parsed with the request schema, which omits `user`: identity is asserted by + // the session, not by the caller's payload. + const requestedClientInfo = ClientInfoRequestSchema.parse(req.body); + const clientInfo = effectiveClientInfo(user, requestedClientInfo); + + if (!getHost(clientInfo.hostId)) { + throw new BadRequestError("Host is not available."); + } + + const existingClient = getClient(clientInfo.clientId); + if ( + existingClient && + !isBrowserClientInfo(clientInfo) && + !verifyClient(clientInfo) + ) { + throw new BadRequestError("Client verification failed."); + } + + res.json({ + success: true, + data: { + wsToken: createAppWebSocketToken(clientInfo), + expiresIn: APP_WEBSOCKET_TOKEN_TTL_SECONDS, + clientId: clientInfo.clientId, + }, + }); +}); + +router.post("/logout", (req, res) => { + const accessToken = getBearerToken(req) ?? getCookie(req, AUTH_ACCESS_COOKIE); + if (accessToken) { + revokeSessionByAccessToken(accessToken); + } + const refresh = RefreshSchema.safeParse(req.body); + if (refresh.success) { + revokeSessionByRefreshToken(refresh.data.refreshToken); + } + const refreshCookie = getCookie(req, AUTH_REFRESH_COOKIE); + if (refreshCookie) { + revokeSessionByRefreshToken(refreshCookie); + } + clearAuthCookies(res); + res.json({ success: true }); +}); + +router.delete("/oauth/accounts/:provider", authLimiter, (req, res) => { + const user = requireAuthUser(req); + const provider = assertProvider(String(req.params.provider)); + res.json({ + success: true, + data: { + user: authUserResponse(req, unlinkProviderAccount(user.id, provider)), + }, + }); +}); + +export function requireAuthUser(req: express.Request) { + const token = getBearerToken(req) ?? getCookie(req, AUTH_ACCESS_COOKIE); + if (!token) throw new ForbiddenError("Authentication required."); + const user = validateAccessToken(token); + if (!user) throw new ForbiddenError("Authentication required."); + return user; +} + +function getBearerToken(req: express.Request): string | null { + const header = req.headers.authorization; + if (!header?.startsWith("Bearer ")) return null; + return header.slice("Bearer ".length).trim() || null; +} + +/** + * Binds the authenticated account to the client's self-reported device info. + * `user` is always taken from the session, so a spoofed value in the request + * body cannot reach the websocket token or the CLI's approval prompt. + */ +function effectiveClientInfo( + user: AuthUser, + clientInfo: ClientInfoRequest, +): AuthedClientInfo { + return { + ...clientInfo, + // Browser clients have no stable per-install id, so the account id doubles + // as the client id — one browser client per user. + clientId: isBrowserClientInfo(clientInfo) ? user.id : clientInfo.clientId, + user: { id: user.id, email: user.email }, + }; +} + +function isBrowserClientInfo(clientInfo: ClientInfoRequest): boolean { + return clientInfo.platform === "browser"; +} + +function appCallbackUrl(req: express.Request): string { + const { callbackUrl: requestedCallbackUrl } = OAuthStartBodySchema.parse( + req.body ?? {}, + ); + if (!requestedCallbackUrl) return env.AUTH_APP_CALLBACK_URL; + + let url: URL; + try { + url = new URL(requestedCallbackUrl); + } catch { + throw new BadRequestError("Invalid app callback URL."); + } + + if (isBrowserAppCallbackUrl(req, url)) { + url.hash = ""; + return url.toString(); + } + + if (url.host !== "auth-callback") { + throw new BadRequestError("Invalid app callback URL."); + } + + const defaultScheme = new URL(env.AUTH_APP_CALLBACK_URL).protocol; + const allowedSchemes = new Set([ + defaultScheme, + "shellular:", + "shellular-dev:", + ]); + if (!allowedSchemes.has(url.protocol)) { + throw new BadRequestError("Invalid app callback URL."); + } + + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function isBrowserAppCallbackUrl(req: express.Request, url: URL): boolean { + if (!isBrowserCallbackUrl(url.toString())) return false; + + const requestOrigin = req.headers.origin; + if (!requestOrigin) { + return false; + } + + let origin: URL; + try { + origin = new URL(requestOrigin); + } catch { + return false; + } + + if (origin.origin !== url.origin) { + return false; + } + if (env.NODE_ENV === "dev") { + return true; + } + + return isTrustedWebAppOrigin(url); +} + +function isTrustedWebAppOrigin(url: URL): boolean { + if (url.protocol !== "https:") { + return false; + } + return ( + url.hostname === "shellular.dev" || url.hostname.endsWith(".shellular.dev") + ); +} + +function isBrowserCallbackUrl(callbackUrl: string | null | undefined): boolean { + if (!callbackUrl) return false; + try { + const url = new URL(callbackUrl); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + !url.username && + !url.password && + url.searchParams.get("shellularAuthCallback") === "1" + ); + } catch { + return false; + } +} + +function callbackUrl( + params: Record, + appCallbackUrl?: string | null, +): string { + const url = new URL(appCallbackUrl ?? env.AUTH_APP_CALLBACK_URL); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return url.toString(); +} + +function completeOAuthCallback(result: { + profile: ProviderProfile; + loginState: LoginState; +}): Record { + if (result.loginState.purpose === "link") { + if (!result.loginState.userId) { + throw new BadRequestError("Invalid account-linking request."); + } + return { + linkCode: createLinkCode(result.loginState.userId, result.profile), + }; + } + + const user = upsertUserFromProvider(result.profile); + return { code: createExchangeCode(user.id) }; +} + +function completeBrowserOAuthCallback( + res: express.Response, + result: { + profile: ProviderProfile; + loginState: LoginState; + }, +): Record { + if (result.loginState.purpose === "link") { + if (!result.loginState.userId) { + throw new BadRequestError("Invalid account-linking request."); + } + linkProviderAccount(result.loginState.userId, result.profile); + return { linked: "1" }; + } + + const user = upsertUserFromProvider(result.profile); + setAuthCookies(res, createSession(user.id)); + return { authenticated: "1" }; +} + +function setAuthCookies(res: express.Response, tokenPair: TokenPair): void { + res.cookie(AUTH_ACCESS_COOKIE, tokenPair.accessToken, { + ...authCookieOptions(), + expires: new Date(tokenPair.accessTokenExpiresAt), + }); + res.cookie(AUTH_REFRESH_COOKIE, tokenPair.refreshToken, { + ...authCookieOptions(), + expires: new Date(tokenPair.refreshTokenExpiresAt), + }); +} + +function tokenPairResponse( + req: express.Request, + tokenPair: TokenPair, +): TokenPair { + return { + ...tokenPair, + user: authUserResponse(req, tokenPair.user), + }; +} + +function browserSessionData(req: express.Request, tokenPair: TokenPair) { + return { + accessTokenExpiresAt: tokenPair.accessTokenExpiresAt, + refreshTokenExpiresAt: tokenPair.refreshTokenExpiresAt, + user: authUserResponse(req, tokenPair.user), + }; +} + +function authUserResponse(req: express.Request, user: AuthUser): AuthUser { + return { + ...user, + avatarUrl: proxiedImageUrl(req, user.avatarUrl), + }; +} + +function proxiedImageUrl( + req: express.Request, + imageUrl: string | null, +): string | null { + if (!imageUrl) return null; + try { + const url = new URL( + "/utils/image-proxy", + `${req.protocol}://${req.get("host")}`, + ); + url.searchParams.set("url", imageUrl); + return url.toString(); + } catch { + return imageUrl; + } +} + +function clearAuthCookies(res: express.Response): void { + res.clearCookie(AUTH_ACCESS_COOKIE, authCookieOptions()); + res.clearCookie(AUTH_REFRESH_COOKIE, authCookieOptions()); +} + +function authCookieOptions(): express.CookieOptions { + return { + httpOnly: true, + path: "/", + sameSite: env.NODE_ENV === "dev" ? "none" : "lax", + secure: true, + }; +} + +function getCookie(req: express.Request, name: string): string | null { + const header = req.headers.cookie; + if (!header) return null; + for (const cookie of header.split(";")) { + const [rawName, ...rawValue] = cookie.trim().split("="); + if (rawName === name) { + return decodeURIComponent(rawValue.join("=")); + } + } + return null; +} + +function callbackError(error: unknown): string { + if ( + error instanceof BadRequestError || + error instanceof ForbiddenError || + error instanceof ConflictError + ) { + return error.message; + } + return "oauth_failed"; +} diff --git a/src/routes/host.ts b/src/routes/host.ts index be3a59c..1d0543b 100644 --- a/src/routes/host.ts +++ b/src/routes/host.ts @@ -8,7 +8,13 @@ import { ForbiddenError, TooManyRequestsError } from "@/error/http"; import { logger } from "@/logger"; import { userAgentFilter } from "@/middleware/user-agent-filter"; -export const router = Router(); +const router = Router(); +const ROUTE_PREFIX = "/host"; + +export default { + router, + prefix: ROUTE_PREFIX, +}; const registerLimiter = rateLimit({ windowMs: 24 * 60 * 60 * 1000, // 24 hours @@ -49,7 +55,7 @@ const HostRegisterReqSchema = z.object({ }); router.post( - "/host/register", + "/register", userAgentFilter([/^shellular\/\d+\.\d+\.\d+$/]), registerLimiter, (req, res) => { diff --git a/src/routes/notices.ts b/src/routes/notices.ts index 6a91134..87fc75d 100644 --- a/src/routes/notices.ts +++ b/src/routes/notices.ts @@ -2,7 +2,13 @@ import { Router } from "express"; import { getNotices } from "@/notices"; -export const router = Router(); +const router = Router(); +const ROUTE_PREFIX = ""; + +export default { + router, + prefix: ROUTE_PREFIX, +}; // Public: the app fetches this to decide whether to show a notice popup. // Cached in memory server-side; safe to hit frequently. diff --git a/src/routes/utils.ts b/src/routes/utils.ts new file mode 100644 index 0000000..3e4599b --- /dev/null +++ b/src/routes/utils.ts @@ -0,0 +1,122 @@ +import { Router } from "express"; +import { z } from "zod"; +import { BadRequestError } from "@/error/http"; + +const router = Router(); +const ROUTE_PREFIX = ""; + +export default { + router, + prefix: ROUTE_PREFIX, +}; + +const ImageProxyQuerySchema = z.object({ + url: z.string().min(1), +}); + +const TRUSTED_IMAGE_HOSTS = new Set([ + "lh3.googleusercontent.com", + "avatars.githubusercontent.com", + "github.com", +]); +const TRUSTED_IMAGE_TYPES = new Set([ + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]); +const MAX_IMAGE_BYTES = 1024 * 1024; +const MAX_IMAGE_REDIRECTS = 3; + +router.get("/utils/image-proxy", async (req, res) => { + const { url: rawUrl } = ImageProxyQuerySchema.parse(req.query); + const url = parseTrustedImageUrl(rawUrl); + + const response = await fetchTrustedImage(url); + + if (!response.ok) { + throw new BadRequestError("Image could not be loaded."); + } + + const contentType = (response.headers.get("content-type") ?? "") + .split(";")[0] + .trim() + .toLowerCase(); + if (!TRUSTED_IMAGE_TYPES.has(contentType)) { + throw new BadRequestError("URL is not an image."); + } + + const contentLength = Number(response.headers.get("content-length") ?? "0"); + if (contentLength > MAX_IMAGE_BYTES) { + throw new BadRequestError("Image is too large."); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_IMAGE_BYTES) { + throw new BadRequestError("Image is too large."); + } + + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", String(bytes.byteLength)); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader( + "Cache-Control", + "public, max-age=86400, stale-while-revalidate=604800", + ); + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + res.end(Buffer.from(bytes)); +}); + +async function fetchTrustedImage(rawUrl: string): Promise { + let url = rawUrl; + for ( + let redirectCount = 0; + redirectCount <= MAX_IMAGE_REDIRECTS; + redirectCount++ + ) { + const response = await fetch(url, { + headers: { + Accept: "image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8", + "User-Agent": "Shellular image proxy", + }, + redirect: "manual", + }); + + if (!isRedirectResponse(response)) { + return response; + } + + const location = response.headers.get("location"); + if (!location) { + throw new BadRequestError("Image redirect is invalid."); + } + + url = parseTrustedImageUrl(new URL(location, url).toString()); + } + + throw new BadRequestError("Image redirected too many times."); +} + +function isRedirectResponse(response: Response): boolean { + return response.status >= 300 && response.status < 400; +} + +function parseTrustedImageUrl(rawUrl: string): string { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new BadRequestError("Invalid image URL."); + } + + if (url.protocol !== "https:") { + throw new BadRequestError("Image URL must use HTTPS."); + } + if (!TRUSTED_IMAGE_HOSTS.has(url.hostname)) { + throw new BadRequestError("Image host is not allowed."); + } + + url.hash = ""; + return url.toString(); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9723a78 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ +import type express from "express"; + +/** + * A router together with the path it is mounted at. + * A convention we'll follow to export default all router modules from their respective files. + */ +export type RouteModule = { + router: express.Router; + prefix: string; +}; diff --git a/src/utils/express.ts b/src/utils/express.ts index fd2b999..7cf21ba 100644 --- a/src/utils/express.ts +++ b/src/utils/express.ts @@ -4,9 +4,28 @@ import type express from "express"; import { logger } from "@/logger"; +import type { RouteModule } from "@/types"; + +function joinPaths(prefix: string, path: string): string { + if (!prefix) return path; + if (path === "/") return prefix; + return `${prefix}${path}`; +} + +/** + * Function to print all registered routes in a pretty format. + * @param app - The Express application instance. + * @param routes - The route modules mounted on the app. Explicitly Needed because prefix info is not available against a router. + */ +export function printRoutes( + app: ReturnType, + routes: readonly RouteModule[] = [], +): void { + const prefixByRouter = new Map(); + for (const { router, prefix } of routes) { + prefixByRouter.set(router, prefix); + } -// Function to print all registered routes in a pretty format -export function printRoutes(app: ReturnType): void { logger.info("Registered Routes:"); logger.info("─".repeat(60)); @@ -28,7 +47,10 @@ export function printRoutes(app: ReturnType): void { } routeMap.get(path)!.push(...methods); } else if (middleware.name === "router") { - // Routes from mounted routers + // Routes from mounted routers. Routers mounted at the root have no + // prefix, so they fall back to "". + const prefix = prefixByRouter.get(middleware.handle) ?? ""; + (middleware.handle as any).stack.forEach((handler: any) => { if (handler.route) { const methods = Object.keys(handler.route.methods) @@ -39,7 +61,8 @@ export function printRoutes(app: ReturnType): void { ? handler.route.path : [handler.route.path]; - paths.forEach((path: string) => { + paths.forEach((routePath: string) => { + const path = joinPaths(prefix, routePath); if (!routeMap.has(path)) { routeMap.set(path, []); } diff --git a/src/websocket/index.ts b/src/websocket/index.ts index 238f9bc..a54a0f4 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -1,13 +1,21 @@ import type http from "node:http"; import type { Duplex } from "node:stream"; -import { ClientInfoSchema } from "@shellular/protocol"; +import { + type ClientInfoRequest, + ClientInfoRequestSchema, +} from "@shellular/protocol"; import { z } from "zod"; +import { + type AppWebSocketTokenPayload, + verifyAppWebSocketToken, +} from "@/auth/ws-app-ticket"; import { getClient, verifyClient } from "@/db/client"; import { getHost } from "@/db/host"; import { env } from "@/env"; import { logger } from "@/logger"; +import { captureAppConnection, captureLegacyAppConnection } from "@/posthog"; import { getActiveSessionForHost, joinSession } from "./sessions"; import { CloseCodeAndReason, closeWsWithError } from "./shared"; import { initAppWebSocket, requestClientApprovalFromHost } from "./ws-app"; @@ -17,6 +25,18 @@ const HostQuerySchema = z.object({ hostId: z.string(), }); +const AppAuthQuerySchema = z + .object({ + wsToken: z.string().min(1), + }) + .strict(); + +type AppConnectionAuth = + | { clientInfo: AppWebSocketTokenPayload; userId: string; legacy: false } + // Legacy clients predate the wsToken handshake, so no identity was ever + // proven for them + | { clientInfo: ClientInfoRequest; userId: null; legacy: true }; + const cliWsServer = initCliWebSocket(); const appWsServer = initAppWebSocket(); @@ -73,18 +93,19 @@ async function handleUpgradeRequest( return; } - // async approval before upgrade is fine - const parsed = ClientInfoSchema.safeParse(query); + const auth = parseAppConnectionAuth(query); + if (!auth) { + logger.warn("Rejecting app websocket: missing or invalid app auth query"); + socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } - appWsServer.handleUpgrade(request, socket, head, async (ws) => { - if (!parsed.success) { - const { code, reason } = CloseCodeAndReason.INVALID_QUERY; - logger.info("Rejecting app websocket: invalid query"); - closeWsWithError(ws, code, reason); - return; - } + const { clientInfo } = auth; - const { hostId } = parsed.data; + // async approval before upgrade is fine + appWsServer.handleUpgrade(request, socket, head, async (ws) => { + const { hostId } = clientInfo; const host = getHost(hostId); if (!host) { logger.info(`Rejecting app websocket: host ${hostId} doesn't exist`); @@ -93,10 +114,14 @@ async function handleUpgradeRequest( return; } - const existingClient = getClient(parsed.data.clientId); - if (existingClient && !verifyClient(parsed.data)) { + const existingClient = getClient(clientInfo.clientId); + if ( + existingClient && + clientInfo.platform !== "browser" && + !verifyClient(clientInfo) + ) { logger.info( - `Rejecting app websocket: client verification failed for clientId=${parsed.data.clientId}`, + `Rejecting app websocket: client verification failed for clientId=${clientInfo.clientId}`, ); const { code, reason } = CloseCodeAndReason.INVALID_QUERY; closeWsWithError(ws, code, reason); @@ -113,25 +138,30 @@ async function handleUpgradeRequest( return; } - const failure = await requestClientApprovalFromHost(session, parsed.data); + const failure = await requestClientApprovalFromHost(session, clientInfo); if (failure) { logger.info( - `Rejecting app websocket: approval denied for hostId=${hostId} clientId=${parsed.data.clientId} reason=${failure.reason}`, + `Rejecting app websocket: approval denied for hostId=${hostId} clientId=${clientInfo.clientId} reason=${failure.reason}`, ); closeWsWithError(ws, failure.code, failure.reason); return; } - const joined = joinSession(session.id, ws, parsed.data); + const joined = joinSession(session.id, ws, clientInfo); if (!joined) { const { code, reason } = CloseCodeAndReason.SESSION_JOIN_FAILED; logger.info( - `Rejecting app websocket: join failed for hostId=${hostId} clientId=${parsed.data.clientId}`, + `Rejecting app websocket: join failed for hostId=${hostId} clientId=${clientInfo.clientId}`, ); closeWsWithError(ws, code, reason); return; } + if (auth.userId) { + captureAppConnection(auth.userId, clientInfo, host.platform); + } else { + captureLegacyAppConnection(clientInfo, host.platform); + } appWsServer.emit("connection", ws, request); }); @@ -143,6 +173,37 @@ async function handleUpgradeRequest( socket.destroy(); } +function parseAppConnectionAuth( + query: Record, +): AppConnectionAuth | null { + if ("wsToken" in query) { + const authParsed = AppAuthQuerySchema.safeParse(query); + if (!authParsed.success) return null; + + const clientInfo = verifyAppWebSocketToken(authParsed.data.wsToken); + if (!clientInfo) { + logger.warn("Rejecting app websocket: invalid or expired wsToken"); + return null; + } + + return { clientInfo, userId: clientInfo.user.id, legacy: false }; + } + + // Legacy path: identity is unproven, so strip any `user` a caller tried to + // smuggle in through the query string rather than forwarding it to the CLI. + const legacyParsed = ClientInfoRequestSchema.safeParse(query); + if (!legacyParsed.success) return null; + if (legacyParsed.data.platform === "browser") { + logger.warn("Rejecting legacy browser websocket without wsToken"); + return null; + } + + logger.warn( + `Accepting legacy unauthenticated app websocket for clientId=${legacyParsed.data.clientId} hostId=${legacyParsed.data.hostId}`, + ); + return { clientInfo: legacyParsed.data, userId: null, legacy: true }; +} + const APP_PROTOCOL = "shellular:"; const WEB_PROTOCOLS = new Set(["https:", "wss:"]); diff --git a/src/websocket/sessions.ts b/src/websocket/sessions.ts index ee49cd5..ed6e5a4 100644 --- a/src/websocket/sessions.ts +++ b/src/websocket/sessions.ts @@ -87,6 +87,13 @@ export function joinSession( info: clientInfo, }; + const existingClient = session.clients.get(clientInfo.clientId); + if (existingClient && existingClient.ws !== clientWs) { + socketToSession.delete(existingClient.ws); + const { code, reason } = CloseCodeAndReason.CLIENT_REPLACED; + existingClient.ws.close(code, reason); + } + connections.clients.add(clientInfo.clientId); session.clients.set(clientInfo.clientId, clientInfoWithWs); socketToSession.set(clientWs, { @@ -117,6 +124,11 @@ export function getActiveSessionForHost(hostId: string): Session | null { return sessionsByHostId.get(hostId) ?? null; } +/** All currently-connected host sessions, for periodic sweeps (e.g. analytics). */ +export function getActiveHostSessions(): Session[] { + return [...sessionsByHostId.values()]; +} + export function getSessionStats() { return { hosts: connections.hosts.size, diff --git a/src/websocket/ws-cli.ts b/src/websocket/ws-cli.ts index 6505e93..36fb994 100644 --- a/src/websocket/ws-cli.ts +++ b/src/websocket/ws-cli.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { getHost, verifyHost } from "@/db/host"; import { logger } from "@/logger"; +import { captureCliConnection } from "@/posthog"; import { type HostToClientMsg, HostToClientMsgSchema } from "./protocol"; import { createSession, @@ -206,6 +207,7 @@ function handleAuth(ws: WebSocket, msg: SessionHostMsg): Session { } const session = createSession(hostId, ws, msg.data); + captureCliConnection(session.hostInfo); // complete handshake with the host (CLI) const hostedId = `server_${nanoid(8)}`;