Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,30 @@
# replace with your own values
# Replace with your own values.

PORT=3000
CORS_ORIGIN=https://shellular.dev
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=
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ mermaid/

*.local

credentials.json
credentials.json
*.p8
133 changes: 133 additions & 0 deletions docs/oauth-flow.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
90 changes: 85 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +35,26 @@ 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 <accessToken>` 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=<ticket>`.

The `/cli` WebSocket and `/host/register` flow are unchanged.

## User History

Successful app joins are recorded in `user_connection_history` for read-only account history. `GET /auth/history` returns the authenticated user's host and device history for display in the app. This data is intended for visibility, history, and analytics only; it is not used for host sync or E2EE key sync.

## 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.
Expand Down
9 changes: 9 additions & 0 deletions src/auth/crypto.ts
Original file line number Diff line number Diff line change
@@ -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");
}
Loading