Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ jobs:
name: Test and validate Workers
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: postgres:17
env:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
AUTH_TEST_DATABASE_URL: postgresql://postgres@localhost:5432/postgres
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,27 @@ jobs:
cache: npm
- run: npm ci
- run: npm run build:css
- name: Validate authentication email configuration
run: npm run auth:validate-email --workspace=factorize
env:
APP_ORIGIN: https://app.factorize.sh
POSTMARK_SERVER_TOKEN: ${{ secrets.POSTMARK_SERVER_TOKEN }}
POSTMARK_FROM_EMAIL: ${{ secrets.POSTMARK_FROM_EMAIL }}
POSTMARK_MESSAGE_STREAM: ${{ secrets.POSTMARK_MESSAGE_STREAM }}
- name: Apply database migrations
run: npm run db:migrate --workspace=factorize
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Configure authentication email Worker secrets
working-directory: packages/app
run: |
node -e 'process.stdout.write(JSON.stringify(Object.fromEntries(["POSTMARK_SERVER_TOKEN", "POSTMARK_FROM_EMAIL", "POSTMARK_MESSAGE_STREAM"].map(key => [key, process.env[key]]))))' | npx wrangler secret bulk
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
POSTMARK_SERVER_TOKEN: ${{ secrets.POSTMARK_SERVER_TOKEN }}
POSTMARK_FROM_EMAIL: ${{ secrets.POSTMARK_FROM_EMAIL }}
POSTMARK_MESSAGE_STREAM: ${{ secrets.POSTMARK_MESSAGE_STREAM }}
- name: Deploy app Worker
run: npm run deploy:app
env:
Expand Down
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,33 @@ Fill `.dev.vars` with the Linear OAuth credentials, webhook signing secret, and
openssl rand -base64 32
```

## Configure Linear

## Account authentication

Factorize stores first-party accounts in the `AuthStore` Durable Object. Users can create an account and sign in with an email address and a password of at least 12 characters at `/auth/signup` and `/auth/login`; the existing Linear connection remains available from the same sign-in page. Passwords are salted PBKDF2-SHA-256 hashes and reset tokens are single-use, expire after one hour, and revoke existing sessions when consumed.
Factorize owns authentication in PostgreSQL. Create a native account at `/auth/signup` with a unique username (3–32 letters, numbers, underscores or hyphens), an email address, and a password of 12–200 characters. Usernames and emails are case-insensitive. Verify the email before signing in at `/auth/login`; username or email plus password are accepted. Linear is connected after sign-in from **Settings → Integrations**, only for Linear-backed jobs.

Postmark sends one-hour, single-use verification and password-reset links. `/auth/verify/request` resends verification; `/auth/password-reset` handles recovery. Links never appear in application responses or logs. Passwords use salted PBKDF2-SHA-256. **Change password** in the account menu requires the current password. Password reset, password change and sign-out invalidate existing sessions via membership session versions, including delegated API credentials.

Before deploying, configure these **production GitHub environment secrets** (the deployment copies them to Worker secrets):

| Setting | Required value |
| --- | --- |
| `POSTMARK_SERVER_TOKEN` | Postmark **server** API token for the sending server; never an account token or a committed value |
| `POSTMARK_FROM_EMAIL` | Plain email address on a verified Postmark sender signature or verified domain |
| `POSTMARK_MESSAGE_STREAM` | Active **transactional** stream ID, commonly `outbound` |

`APP_ORIGIN` must be the canonical HTTPS origin, without a trailing slash. Deploy runs `npm run auth:validate-email --workspace=factorize` **before migrations**: it checks the server token/stream with Postmark and sends one preflight email from/to the configured sender, rejecting unverified senders or sending failures. It prints no credentials. Missing configuration stops deployment. Runtime signup and recovery also fail closed if configuration or delivery fails; users can resend verification after a delivery failure. Local mail testing needs an HTTPS origin and a separate Postmark test server/sender; automated tests mock Postmark and never send mail.

The `v4-auth-store` Durable Object migration must be deployed before using first-party authentication. Configure `SESSION_SIGNING_SECRET` as usual. For local development only, `AUTH_RESET_RETURN_TOKEN=true` makes the reset endpoint return its token so the flow can be exercised without an email provider; leave it unset or false in production. Transactional email delivery can consume reset events from the auth store without exposing whether an address is registered.
### Existing account migration

Apply `0008_native_accounts.sql` before deploying this code. It rejects ambiguous legacy emails that differ only in case (reconcile those identities before retrying migration), preserves tenant IDs, memberships, jobs and encrypted Linear connections, and backfills explicit Linear organization-to-workspace mappings. Existing Linear-only users choose **Forgot password or previously signed in with Linear?**, receive a reset email at their existing address, and choose a username and password. Email possession is required; signup cannot replace an existing account's credentials. Existing native users keep their passwords and may sign in by email; unverified accounts must verify first. A reset never promotes a member to owner. An account with multiple existing owner memberships currently opens the oldest workspace; no new workspace-switching UI is introduced.

Linear OAuth requires an authenticated owner, binds state to that user/workspace, stores the connection there, and never creates a Factorize session. A Linear organization already bound to another workspace cannot be claimed. Existing webhook routes resolve the explicit mapping. Do not delete that binding to work around an account-recovery problem.

### Authentication verification

`npm run check` runs unit/contract tests and Postmark preflight tests. CI also runs the real PostgreSQL authentication suite against a disposable service. To run it locally, set `AUTH_TEST_DATABASE_URL` to an isolated PostgreSQL server whose user can create/drop test databases, then run `npm test --workspace=factorize -- --run test/native-auth.postgres.test.ts`. The suite creates and removes its own database and applies every migration, including a legacy connection fixture before the auth migration.

## Configure Linear

Use authorization-code OAuth and enable **Webhooks** on the OAuth application. Configure:

Expand Down Expand Up @@ -147,7 +167,7 @@ Read the complete, implementation-accurate API and MCP documentation at [docs.fa

API clients use OAuth 2.1 authorization code flow with PKCE S256 or the OAuth 2.0 Device Authorization Grant for headless environments. Factorize publishes authorization-server and protected-resource discovery metadata, supports Client ID Metadata Documents, and retains dynamic client registration at `/oauth/register` for older clients. Access tokens last one hour and may be refreshed for up to 30 days; RFC 7009 revocation is advertised by discovery metadata.

Available scopes are `flows:read`, `flows:write`, `runs:read`, and `runs:write`. The resource owner must sign in through the normal Linear-backed Factorize session and explicitly approve the requested scopes. Factorize rechecks owner membership and session version on every service call.
Available scopes are `flows:read`, `flows:write`, `runs:read`, and `runs:write`. The resource owner must sign in through the native Factorize session and explicitly approve the requested scopes. Factorize rechecks owner membership and session version on every service call.

The versioned API is rooted at `/api/v1`:

Expand Down Expand Up @@ -186,7 +206,7 @@ Configure a compatible remote MCP client with this single URL:
https://app.factorize.sh/mcp
```

The client discovers OAuth automatically, opens Factorize in a browser, completes Linear sign-in if necessary, requests consent, and returns to the client after PKCE authorization. No Linear or exe.dev credential is copied into the MCP client. The server is stateless Streamable HTTP and exposes Job CRUD, run inspection/filtering, provider webhook activity, and active-run stopping tools.
The client discovers OAuth automatically, opens Factorize in a browser, completes native Factorize sign-in if necessary, requests consent, and returns to the client after PKCE authorization. No Linear or exe.dev credential is copied into the MCP client. The server is stateless Streamable HTTP and exposes Job CRUD, run inspection/filtering, provider webhook activity, and active-run stopping tools.

Factorize also supports the OAuth 2.0 Device Authorization Grant (RFC 8628) for headless clients. Discovery advertises `device_authorization_endpoint`; clients obtain a code from `POST /oauth/device_authorization`, direct the user to `/device`, and poll `/oauth/token` with grant type `urn:ietf:params:oauth:grant-type:device_code`. Device codes expire after ten minutes, polling is rate-limited, and approved grants use the same scoped access and refresh tokens, tenant checks, and revocation behavior as browser PKCE authorization.

Expand Down
7 changes: 5 additions & 2 deletions packages/app/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ CLICKUP_CLIENT_SECRET=
CREDENTIAL_ENCRYPTION_KEY=
# a separate random value for signed browser sessions
SESSION_SIGNING_SECRET=
# Set only in local development to return reset tokens in the response. Keep unset in production.
AUTH_RESET_RETURN_TOKEN=false
# Use an HTTPS APP_ORIGIN and a separate Postmark server for development.
POSTMARK_SERVER_TOKEN=
# Must be verified in Postmark (plain email address).
POSTMARK_FROM_EMAIL=
POSTMARK_MESSAGE_STREAM=outbound
17 changes: 17 additions & 0 deletions packages/app/migrations/0008_native_accounts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Fail closed on ambiguous legacy email identities; reconcile any duplicates before deployment.
CREATE UNIQUE INDEX auth_users_email_normalized ON app.auth_users(lower(email));
ALTER TABLE app.auth_users ADD COLUMN username text UNIQUE;
ALTER TABLE app.auth_users ADD CONSTRAINT auth_users_username_check CHECK (username IS NULL OR username ~ '^[a-z0-9_][a-z0-9_-]{2,31}$');
ALTER TABLE app.auth_reset_tokens ADD COLUMN purpose text NOT NULL DEFAULT 'reset' CHECK (purpose IN ('verify','reset'));
CREATE TABLE app.linear_workspaces (
organization_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL UNIQUE REFERENCES app.tenants(id) ON DELETE CASCADE
);
-- Legacy Linear login used the organization UUID as the tenant UUID. Preserve
-- these bindings and all existing encrypted credentials and tenant data.
INSERT INTO app.linear_workspaces(organization_id,tenant_id)
SELECT tenant_id,tenant_id FROM app.connections WHERE kind='linear';

-- Retire browser/API sessions issued to unverified users by the old signup path.
UPDATE app.members m SET session_version=session_version+1
FROM app.auth_users u WHERE u.id=m.user_id AND NOT u.email_verified;
5 changes: 3 additions & 2 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
"scripts": {
"dev": "npm run build:css && wrangler dev",
"build:css": "tailwindcss -i ./src/tailwind.css -o ./public/styles.css --minify",
"check": "npm run check:api-boundaries && tsc --noEmit && vitest run && wrangler deploy --dry-run",
"check": "npm run check:api-boundaries && node --test scripts/validate-auth-email.check.mjs && tsc --noEmit && vitest run && wrangler deploy --dry-run",
"check:api-boundaries": "node scripts/check-api-boundaries.mjs",
"test": "vitest run",
"deploy": "wrangler deploy",
"db:migrate": "node scripts/migrate.mjs",
"db:verify": "node scripts/verify-schema.mjs",
"db:studio": "drizzle-kit studio --config drizzle.config.ts"
"db:studio": "drizzle-kit studio --config drizzle.config.ts",
"auth:validate-email": "node scripts/validate-auth-email.mjs"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.10.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/app/public/styles.css

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions packages/app/scripts/validate-auth-email.check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { validatePostmark } from "./validate-auth-email.mjs";
const config = { APP_ORIGIN: "https://app.example.test", POSTMARK_SERVER_TOKEN: "test", POSTMARK_FROM_EMAIL: "accounts@example.test", POSTMARK_MESSAGE_STREAM: "outbound" };
test("Postmark preflight fails closed for missing settings, wrong stream and unverified sender", async () => {
for (const field of Object.keys(config)) await assert.rejects(validatePostmark({ ...config, [field]: "" }), /Configure/);
await assert.rejects(validatePostmark({ ...config, APP_ORIGIN: "http://app.example.test" }), /HTTPS/);
await assert.rejects(validatePostmark(config, async () => Response.json({ MessageStreamType: "Broadcasts" })), /transactional/);
await assert.rejects(validatePostmark(config, async () => Response.json({ MessageStreamType: "Transactional", ArchivedAt: "2026-01-01" })), /transactional/);
await assert.rejects(validatePostmark(config, async url => url.endsWith("/email") ? Response.json({ ErrorCode: 400 }, { status: 422 }) : Response.json({ MessageStreamType: "Transactional" })), /sender/);
});
test("Postmark preflight uses the configured stream and validates an actual send to the sender", async () => {
let sent = false;
await validatePostmark(config, async (url, init) => {
assert.equal(init.headers["X-Postmark-Server-Token"], config.POSTMARK_SERVER_TOKEN);
if (!url.endsWith("/email")) { assert.ok(url.endsWith("/outbound")); return Response.json({ MessageStreamType: "Transactional", ArchivedAt: null }); }
const body = JSON.parse(init.body); assert.equal(body.From, config.POSTMARK_FROM_EMAIL); assert.equal(body.To, config.POSTMARK_FROM_EMAIL); assert.equal(body.MessageStream, "outbound"); sent = true;
return Response.json({ ErrorCode: 0 });
});
assert.equal(sent, true);
});
25 changes: 25 additions & 0 deletions packages/app/scripts/validate-auth-email.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { pathToFileURL } from "node:url";

/** Validate deployment credentials without printing them. Sends one preflight email to the sender. */
export async function validatePostmark(env, request = fetch) {
const { APP_ORIGIN, POSTMARK_SERVER_TOKEN, POSTMARK_FROM_EMAIL, POSTMARK_MESSAGE_STREAM } = env;
if (!APP_ORIGIN || !POSTMARK_SERVER_TOKEN?.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(POSTMARK_FROM_EMAIL ?? "") || !POSTMARK_MESSAGE_STREAM?.trim()) throw new Error("Configure APP_ORIGIN, POSTMARK_SERVER_TOKEN, POSTMARK_FROM_EMAIL and POSTMARK_MESSAGE_STREAM before deployment.");
const origin = new URL(APP_ORIGIN);
if (origin.protocol !== "https:" || origin.origin !== APP_ORIGIN) throw new Error("APP_ORIGIN must be an HTTPS origin without a trailing slash.");
const headers = { "X-Postmark-Server-Token": POSTMARK_SERVER_TOKEN, "Content-Type": "application/json", Accept: "application/json" };
const streamResponse = await request(`https://api.postmarkapp.com/message-streams/${encodeURIComponent(POSTMARK_MESSAGE_STREAM)}`, { headers });
const stream = await streamResponse.json();
if (!streamResponse.ok || stream.MessageStreamType !== "Transactional" || stream.ArchivedAt) throw new Error("Postmark requires a valid server token and an active transactional message stream.");
const emailResponse = await request("https://api.postmarkapp.com/email", { method: "POST", headers, body: JSON.stringify({
From: POSTMARK_FROM_EMAIL, To: POSTMARK_FROM_EMAIL, MessageStream: POSTMARK_MESSAGE_STREAM,
Subject: "Factorize authentication email deployment check",
TextBody: `Authentication email configuration for ${APP_ORIGIN} passed the deployment preflight.`,
}) });
const email = await emailResponse.json();
if (!emailResponse.ok || email.ErrorCode !== 0) throw new Error("Postmark rejected the preflight email. Confirm sender/domain verification and server sending permissions.");
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try { await validatePostmark(process.env); console.log("Postmark authentication email preflight passed."); }
catch (error) { console.error(error.message); process.exitCode = 1; }
}
24 changes: 24 additions & 0 deletions packages/app/src/auth-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { validEmail } from "./auth";
import type { Env } from "./types";

export function validateAuthEmailConfig(env: Env): void {
const origin = new URL(env.APP_ORIGIN);
if (origin.protocol !== "https:" || origin.origin !== env.APP_ORIGIN || !env.POSTMARK_SERVER_TOKEN?.trim() || !validEmail(env.POSTMARK_FROM_EMAIL ?? "") || !env.POSTMARK_MESSAGE_STREAM?.trim()) {
throw new Error("Authentication email requires an HTTPS APP_ORIGIN, POSTMARK_SERVER_TOKEN, POSTMARK_FROM_EMAIL and POSTMARK_MESSAGE_STREAM.");
}
}

export async function sendAuthEmail(env: Env, email: string, token: string, purpose: "verify" | "reset"): Promise<void> {
validateAuthEmailConfig(env);
const url = new URL(purpose === "verify" ? "/auth/verify" : "/auth/password-reset", env.APP_ORIGIN);
url.searchParams.set("token", token);
const response = await fetch("https://api.postmarkapp.com/email", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Postmark-Server-Token": env.POSTMARK_SERVER_TOKEN! },
body: JSON.stringify({ From: env.POSTMARK_FROM_EMAIL, To: email, MessageStream: env.POSTMARK_MESSAGE_STREAM,
Subject: purpose === "verify" ? "Verify your Factorize email" : "Reset your Factorize password",
TextBody: `${purpose === "verify" ? "Verify your email" : "Reset your password"}: ${url}\n\nThis link expires in one hour and can be used once. If you did not request this, ignore this email.` }),
});
const result = await response.json() as { ErrorCode?: number };
if (!response.ok || result.ErrorCode !== 0) throw new Error("Postmark could not deliver authentication email.");
}
3 changes: 3 additions & 0 deletions packages/app/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ export async function verifyPassword(password: string, encoded: string): Promise
}

export async function tokenDigest(token: string, secret: string): Promise<string> { return hmac(token, secret); }

export const normalizeUsername = (value: string): string => value.trim().toLowerCase();
export const validUsername = (value: string): boolean => /^[a-z0-9_][a-z0-9_-]{2,31}$/.test(value);
2 changes: 1 addition & 1 deletion packages/app/src/device-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function deviceLoginRedirect(appOrigin: string, returnTo: string): Respon
status: 302,
headers: {
"Cache-Control": "no-store",
Location: `${appOrigin}/auth/linear`,
Location: `${appOrigin}/auth/login`,
"Set-Cookie": `factorize_oauth_return=${encodeURIComponent(returnTo)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`,
},
});
Expand Down
Loading
Loading