diff --git a/.github/workflows/deploy-prd.yml b/.github/workflows/deploy-prd.yml index 2ba5721b2..fd5848146 100644 --- a/.github/workflows/deploy-prd.yml +++ b/.github/workflows/deploy-prd.yml @@ -43,6 +43,17 @@ jobs: # api Worker (apps/backend/src/worker/env.ts) CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + # Hazel sign-in (Clerk remains available for existing sessions during cutover). + AUTH_GITHUB_CLIENT_ID: ${{ vars.AUTH_GITHUB_CLIENT_ID || secrets.AUTH_GITHUB_CLIENT_ID }} + AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.AUTH_GITHUB_CLIENT_SECRET }} + AUTH_GOOGLE_CLIENT_ID: ${{ vars.AUTH_GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_CLIENT_ID }} + AUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.AUTH_GOOGLE_CLIENT_SECRET }} + AUTH_BINDING_KEY: ${{ secrets.AUTH_BINDING_KEY }} + AUTH_TRANSACTION_KEY: ${{ secrets.AUTH_TRANSACTION_KEY }} + AUTH_ACCESS_TOKEN_KID: ${{ vars.AUTH_ACCESS_TOKEN_KID || secrets.AUTH_ACCESS_TOKEN_KID }} + AUTH_ACCESS_TOKEN_PRIVATE_JWK: ${{ secrets.AUTH_ACCESS_TOKEN_PRIVATE_JWK }} + AUTH_PASSKEY_RP_ID: ${{ vars.AUTH_PASSKEY_RP_ID }} + AUTH_TRUSTED_ORIGINS: ${{ vars.AUTH_TRUSTED_ORIGINS }} CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }} COOKIE_DOMAIN: ${{ vars.COOKIE_DOMAIN }} S3_BUCKET: ${{ vars.S3_BUCKET }} diff --git a/.github/workflows/tauri-release.yml b/.github/workflows/tauri-release.yml index 8c338134b..6f432cc8d 100644 --- a/.github/workflows/tauri-release.yml +++ b/.github/workflows/tauri-release.yml @@ -66,7 +66,6 @@ jobs: VITE_BACKEND_URL: ${{ vars.VITE_BACKEND_URL }} VITE_CLUSTER_URL: ${{ vars.VITE_CLUSTER_URL }} VITE_ELECTRIC_URL: ${{ vars.VITE_ELECTRIC_URL }} - VITE_CLERK_PUBLISHABLE_KEY: ${{ vars.VITE_CLERK_PUBLISHABLE_KEY }} VITE_R2_PUBLIC_URL: ${{ vars.VITE_R2_PUBLIC_URL }} with: projectPath: apps/desktop diff --git a/apps/actors/alchemy.run.ts b/apps/actors/alchemy.run.ts index 1df82e328..327ad21f4 100644 --- a/apps/actors/alchemy.run.ts +++ b/apps/actors/alchemy.run.ts @@ -4,6 +4,7 @@ * `ACTOR_DO` -> `ActorHandler` mapping (alchemy matches the live class by binding name). */ import { HazelStack, hazelWorkerProps, stageProps } from "@hazel/infra/cloudflare" +import { derived, merge, optionalPlain } from "@hazel/infra/env" import * as Cloudflare from "alchemy/Cloudflare" import { Effect } from "effect" @@ -25,6 +26,12 @@ export default Effect.gen(function* () { ACTOR_KV: actorKv, NODE_ENV: stack.stage.kind === "dev" ? "development" : "production", RIVET_PUBLIC_ENDPOINT: stack.urls.rivet, + ...(yield* merge( + // Issuer and JWKS of the access tokens users connect with; also where bot + // tokens are validated. A plain-HTTP dev API needs the key set inline. + derived("API_BASE_URL", stack.urls.api), + optionalPlain("AUTH_ACCESS_TOKEN_JWKS"), + )), }, observability: { enabled: true, diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 0b1237d0a..26c5ba5c6 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -15,6 +15,19 @@ CLERK_SECRET_KEY=sk_test_your_clerk_secret_here CLERK_PUBLISHABLE_KEY=pk_test_your_clerk_publishable_here CLERK_WEBHOOK_SECRET=whsec_your_clerk_webhook_secret_here +# Hazel sign-in (replacing Clerk). Off until AUTH_GITHUB_CLIENT_ID is set. +# GitHub OAuth App callback URL: {API_BASE_URL}/auth/github/callback +# Google OAuth client redirect URI: {API_BASE_URL}/auth/google/callback +# Generate the keys with: bun run --cwd packages/auth generate-keys +# AUTH_GITHUB_CLIENT_ID= +# AUTH_GITHUB_CLIENT_SECRET= +# AUTH_GOOGLE_CLIENT_ID= +# AUTH_GOOGLE_CLIENT_SECRET= +# AUTH_BINDING_KEY= +# AUTH_TRANSACTION_KEY= +# AUTH_ACCESS_TOKEN_KID= +# AUTH_ACCESS_TOKEN_PRIVATE_JWK= + # S3-Compatible Storage (e.g., Cloudflare R2) # Bun auto-reads these S3_* env vars (or AWS_* as fallback) # Get these from your S3-compatible storage provider dashboard diff --git a/apps/backend/package.json b/apps/backend/package.json index 052c8f565..21b68b734 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -38,6 +38,7 @@ "@hazel/infra": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", + "@yielded/auth": "0.1.0-beta.38", "alchemy": "catalog:alchemy", "aws4fetch": "^1.0.20", "dfx": "^1.1.0", diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 545ca14bb..d32b8dad0 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -24,6 +24,7 @@ import { ConnectInviteRepo, ConnectParticipantRepo, CustomEmojiRepo, + InvitationRepo, ChannelWebhookRepo, GitHubSubscriptionRepo, IntegrationConnectionRepo, @@ -48,6 +49,8 @@ import { HazelApi } from "./api" import { HttpApiRoutes } from "./http" import { AttachmentPolicy } from "./policies/attachment-policy" import { BotPolicy } from "./policies/bot-policy" +import { InvitationPolicy } from "./policies/invitation-policy" +import { Invitations } from "./services/invitations" import { ChannelMemberPolicy } from "./policies/channel-member-policy" import { ChannelPolicy } from "./policies/channel-policy" import { ChannelSectionPolicy } from "./policies/channel-section-policy" @@ -76,6 +79,8 @@ import { MockDataGenerator } from "./services/mock-data-generator" import { OAuthBearerAuth } from "./services/oauth-bearer-auth" import { ObjectStorage } from "./services/object-storage" import { OAuthProviderRegistry } from "./services/oauth" +import { HazelAuthInstance, HazelAuthRoutes, HazelAuthServicesLive } from "./services/hazel-auth" +import { HazelSession } from "./services/hazel-session" import { SessionManager } from "./services/session-manager" import { WebhookBotService } from "./services/webhook-bot-service" import { BotGatewayService } from "./services/bot-gateway-service" @@ -103,11 +108,18 @@ const RpcRoute = RpcServer.layerHttp({ protocol: "http", }).pipe(Layer.provide(RpcSerialization.layerNdjson), Layer.provide(RpcServerLive)) -export const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, RpcRoute).pipe( +export const AllRoutes = Layer.mergeAll( + HttpApiRoutes, + HealthRouter, + DocsRoute, + RpcRoute, + HazelAuthRoutes, +).pipe( Layer.provide( HttpRouter.cors({ allowedOrigins: [ "http://localhost:3000", + "http://localhost:3100", "http://localhost:5173", "https://app.hazel.sh", "tauri://localhost", @@ -119,6 +131,16 @@ export const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, ), ) +/** + * Hazel's own sign-in: the configured instance, its auth services, and the session lookup + * the RPC and HttpApi middleware use. Inert until `AUTH_GITHUB_CLIENT_ID` is set. Needs + * `AuthSqlConnection` (and `AuthSqlDirect` on Workers) from the entry point. + */ +export const HazelAuthLive = HazelSession.layer.pipe( + Layer.provideMerge(HazelAuthServicesLive), + Layer.provideMerge(HazelAuthInstance.layer), +) + export const RepoLive = Layer.mergeAll( MessageRepo.layer, ChannelRepo.layer, @@ -151,6 +173,7 @@ export const RepoLive = Layer.mergeAll( BotCommandRepo.layer, BotInstallationRepo.layer, CustomEmojiRepo.layer, + InvitationRepo.layer, ) export const PolicyLive = Layer.mergeAll( @@ -174,6 +197,7 @@ export const PolicyLive = Layer.mergeAll( RssSubscriptionPolicy.layer, BotPolicy.layer, CustomEmojiPolicy.layer, + InvitationPolicy.layer, ) /** @@ -199,10 +223,18 @@ export const AppServicesLive = Layer.mergeAll( WebhookBotService.layer, ChannelAccessSyncService.layer, ConnectConversationService.layer, + Invitations.layer, // SessionManager.layer includes BackendAuth.layer via dependencies SessionManager.layer, OAuthBearerAuth.layer, ).pipe(Layer.provideMerge(FetchHttpClient.layer)) -/** `CurrentUser` resolution for authenticated routes. Requires `Database` from the platform. */ -export const AppAuthorizationLive = AuthorizationLive.pipe(Layer.provideMerge(SessionManager.layer)) +/** + * `CurrentUser` resolution for authenticated routes, and Hazel's sign-in services. Only the + * HTTP entry points build this; the Durable Objects never authenticate. Requires `Database` + * and the auth SQL connection from the platform. + */ +export const AppAuthorizationLive = AuthorizationLive.pipe( + Layer.provideMerge(SessionManager.layer), + Layer.provideMerge(HazelAuthLive), +) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 6809cee28..66dc2f475 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -3,13 +3,16 @@ * `worker.ts`. See infra/cloudflare-migration-plan.md. */ import { BunHttpServer, BunRuntime, BunSocket } from "@effect/platform-bun" +import { layerPool as authSqlPool } from "@hazel/auth/server" import { Redis, RedisResultPersistenceLive } from "@hazel/effect-bun" import { createTracingLayer } from "@hazel/effect-bun/Telemetry" -import { Config, ConfigProvider, Layer } from "effect" +import { Config, ConfigProvider, Effect, Layer } from "effect" import { HttpMiddleware, HttpRouter } from "effect/http" import { AllRoutes, AppAuthorizationLive, AppServicesLive, HazelApi } from "./app" +import { InviteMailer } from "./services/invite-mailer" import { BotGatewayTransport } from "./services/bot-gateway-transport" import { DiscordGatewayBackgroundLive } from "./services/chat-sync/discord-gateway-service" +import { EnvVars } from "./lib/env-vars" import { DatabaseLive } from "./services/database" import { MessageOutboxDispatcher } from "./services/message-outbox-dispatcher" import { RateLimiterRedisLive } from "./services/rate-limiter-redis" @@ -24,16 +27,24 @@ const TracerLive = createTracingLayer("api") // ResultPersistence layer for session caching (uses Redis backing) const PersistenceLive = RedisResultPersistenceLive.pipe(Layer.provide(Redis.Default)) +/** Auth storage's own Postgres pool (Effect SQL; the rest of the backend uses Drizzle). */ +const AuthSqlLive = Layer.unwrap( + Effect.map(Effect.service(EnvVars), (env) => authSqlPool(env.DATABASE_URL, { ssl: !env.IS_DEV })), +).pipe(Layer.provide(EnvVars.layer)) + /** * Bun's platform services: a pooled database, Redis-backed caches and rate limits, and bot gateway * events appended to the Durable Streams server the Bun bot gateway reads. */ const PlatformLive = Layer.mergeAll( DatabaseLive, + AuthSqlLive, PersistenceLive, Redis.Default, RateLimiterRedisLive, BotGatewayTransport.layerDurableStreams, + // No email service on Bun: invite links are logged. + InviteMailer.layerLog, ) /** Long-running loops; on Cloudflare these are Durable Objects driven by crons. */ diff --git a/apps/backend/src/policies/invitation-policy.ts b/apps/backend/src/policies/invitation-policy.ts new file mode 100644 index 000000000..dd0ca1c0f --- /dev/null +++ b/apps/backend/src/policies/invitation-policy.ts @@ -0,0 +1,27 @@ +import { ErrorUtils } from "@hazel/domain" +import type { OrganizationId } from "@hazel/schema" +import { Context, Effect, Layer } from "effect" +import { withAnnotatedScope } from "../lib/policy-utils" +import { OrgResolver } from "../services/org-resolver" + +/** Only an organization's admins and owners invite, list and revoke its invitations. */ +export class InvitationPolicy extends Context.Service()("InvitationPolicy/Policy", { + make: Effect.gen(function* () { + const policyEntity = "Invitation" as const + const orgResolver = yield* OrgResolver + + const canManage = (organizationId: OrganizationId, action: "create" | "select" | "delete") => + ErrorUtils.refailUnauthorized( + policyEntity, + action, + )( + withAnnotatedScope((scope) => + orgResolver.requireAdminOrOwner(organizationId, scope, policyEntity, action), + ), + ) + + return { canManage } as const + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(OrgResolver.layer)) +} diff --git a/apps/backend/src/rpc/handlers/invitations.ts b/apps/backend/src/rpc/handlers/invitations.ts new file mode 100644 index 000000000..23f475314 --- /dev/null +++ b/apps/backend/src/rpc/handlers/invitations.ts @@ -0,0 +1,16 @@ +import { InvitationRpcs } from "@hazel/domain/rpc" +import { Effect } from "effect" +import { Invitations } from "../../services/invitations" + +export const InvitationRpcLive = InvitationRpcs.toLayer( + Effect.gen(function* () { + const invitations = yield* Invitations + return { + "invitation.create": invitations.create, + "invitation.list": invitations.list, + "invitation.revoke": invitations.revoke, + "invitation.preview": invitations.preview, + "invitation.accept": invitations.accept, + } + }), +) diff --git a/apps/backend/src/rpc/handlers/organizations.ts b/apps/backend/src/rpc/handlers/organizations.ts index bf816ca20..d10656709 100644 --- a/apps/backend/src/rpc/handlers/organizations.ts +++ b/apps/backend/src/rpc/handlers/organizations.ts @@ -1,11 +1,9 @@ import type { OrganizationId } from "@hazel/schema" -import { ClerkClient } from "@hazel/auth" import { ChannelMemberRepo, ChannelRepo, OrganizationMemberRepo, OrganizationRepo, - UserRepo, } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/domain" @@ -114,15 +112,12 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( const channelMemberRepo = yield* ChannelMemberRepo const organizationMemberRepo = yield* OrganizationMemberRepo const channelAccessSync = yield* ChannelAccessSyncService - const clerkClient = yield* ClerkClient - const userRepo = yield* UserRepo return { "organization.create": (payload) => Effect.gen(function* () { const currentUser = yield* CurrentUser.Context - // Fail fast on a locally-taken slug before creating anything in Clerk. if (payload.slug) { const existingOrganization = yield* organizationRepo.findBySlug(payload.slug) @@ -138,118 +133,47 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( yield* organizationPolicy.canCreate() - // The creator must become the Clerk org admin, so resolve their Clerk user id. - const userOption = yield* userRepo.findById(currentUser.id) - if (Option.isNone(userOption)) { - return yield* Effect.fail( - new InternalServerError({ - message: "Error creating Organization", - detail: "Could not resolve the current user for Clerk organization creation", - }), - ) - } - - // Create the org in Clerk first — Clerk is the source of truth for org - // membership and powers Clerk-backed features (invitations, member management). - // The inbound webhook later dedupes back to our row via clerkOrganizationId. - const clerkOrg = yield* clerkClient - .createOrganization({ - name: payload.name, - slug: payload.slug, - createdBy: userOption.value.externalId, - }) - .pipe( - Effect.catchTag( - "OrganizationCreateError", - ( - err, - ): Effect.Effect< - never, - OrganizationSlugAlreadyExistsError | InternalServerError - > => { - const detail = err.detail ?? "" - // Clerk rejects duplicate slugs — surface as the slug-taken error. - if ( - payload.slug && - /slug/i.test(detail) && - /(taken|already|exist|unique|duplicate)/i.test(detail) - ) { - return Effect.fail( - new OrganizationSlugAlreadyExistsError({ - message: `Organization slug '${payload.slug}' is already taken`, - slug: payload.slug, - }), - ) - } - return Effect.fail( - new InternalServerError({ - message: "Error creating Organization", - detail: "Failed to create the organization in Clerk", - cause: detail, - }), - ) - }, - ), - ) - - // Run local setup in a transaction. If it fails, delete the Clerk org we just - // created so the two systems don't drift. - return yield* db - .transaction( - Effect.gen(function* () { - const createdOrganization = yield* organizationRepo - .insert({ - name: payload.name, - slug: payload.slug, - logoUrl: payload.logoUrl, - settings: { - ...(payload.settings ?? {}), - clerkOrganizationId: clerkOrg.id, - }, - isPublic: false, - deletedAt: null, - }) - .pipe(Effect.map((res) => res[0]!)) - - yield* organizationMemberRepo.upsertByOrgAndUser({ - organizationId: createdOrganization.id, - userId: currentUser.id, - role: "owner", - nickname: undefined, - joinedAt: new Date(), - invitedBy: null, + return yield* db.transaction( + Effect.gen(function* () { + const createdOrganization = yield* organizationRepo + .insert({ + name: payload.name, + slug: payload.slug, + logoUrl: payload.logoUrl, + settings: payload.settings ?? null, + isPublic: false, deletedAt: null, }) + .pipe(Effect.map((res) => res[0]!)) - // Setup default channels for the organization - yield* organizationRepo.setupDefaultChannels( - createdOrganization.id, - currentUser.id, - ) - - yield* channelAccessSync.syncUserInOrganization( - currentUser.id, - createdOrganization.id, - ) - - const txid = yield* generateTransactionId() - - return new OrganizationResponse({ - data: { - ...createdOrganization, - settings: createdOrganization.settings as { - readonly [x: string]: unknown - } | null, - }, - transactionId: txid, - }) - }), - ) - .pipe( - Effect.onError(() => - clerkClient.deleteOrganization(clerkOrg.id).pipe(Effect.ignore), - ), - ) + yield* organizationMemberRepo.upsertByOrgAndUser({ + organizationId: createdOrganization.id, + userId: currentUser.id, + role: "owner", + nickname: undefined, + joinedAt: new Date(), + invitedBy: null, + deletedAt: null, + }) + + // Setup default channels for the organization + yield* organizationRepo.setupDefaultChannels(createdOrganization.id, currentUser.id) + + yield* channelAccessSync.syncUserInOrganization(currentUser.id, createdOrganization.id) + + const txid = yield* generateTransactionId() + + return new OrganizationResponse({ + data: { + ...createdOrganization, + settings: createdOrganization.settings as { + readonly [x: string]: unknown + } | null, + }, + transactionId: txid, + }) + }), + ) }).pipe(handleOrganizationDbErrors("Organization", "create")), "organization.update": ({ id, ...payload }) => diff --git a/apps/backend/src/rpc/handlers/users-avatar.test.ts b/apps/backend/src/rpc/handlers/users-avatar.test.ts new file mode 100644 index 000000000..e5b69e139 --- /dev/null +++ b/apps/backend/src/rpc/handlers/users-avatar.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest" +import { isAllowedAvatarUrl } from "./users" +describe("profile avatar URL", () => { + it("accepts only this user's CDN upload prefix", () => { + const allowed = (value: string) => isAllowedAvatarUrl(value, "https://cdn.hazel.test", "user-1") + expect(allowed("https://cdn.hazel.test/avatars/user-1/file")).toBe(true) + for (const value of [ + "https://evil.test/avatars/user-1/file", + "https://cdn.hazel.test/avatars/user-2/file", + "https://cdn.hazel.test/avatars/user-1/../user-2/file", + "data:image/svg+xml,bad", + "broken", + ]) + expect(allowed(value)).toBe(false) + expect(isAllowedAvatarUrl("https://cdn.hazel.test/avatars/user-1/file", undefined, "user-1")).toBe( + false, + ) + }) +}) diff --git a/apps/backend/src/rpc/handlers/users.ts b/apps/backend/src/rpc/handlers/users.ts index 007dda6f9..fe2681d9d 100644 --- a/apps/backend/src/rpc/handlers/users.ts +++ b/apps/backend/src/rpc/handlers/users.ts @@ -1,8 +1,8 @@ import { UserRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" -import { CurrentUser, withRemapDbErrors } from "@hazel/domain" +import { CurrentUser, UnauthorizedError, withRemapDbErrors } from "@hazel/domain" import { UserResponse, UserRpcs } from "@hazel/domain/rpc" -import { Effect } from "effect" +import { Config, Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { UserPolicy } from "../../policies/user-policy" @@ -11,6 +11,7 @@ export const UserRpcLive = UserRpcs.toLayer( const db = yield* Database.Database const userPolicy = yield* UserPolicy const userRepo = yield* UserRepo + const publicUrl = yield* Config.option(Config.String("S3_PUBLIC_URL")) return { "user.me": () => CurrentUser.Context, @@ -20,6 +21,17 @@ export const UserRpcLive = UserRpcs.toLayer( .transaction( Effect.gen(function* () { yield* userPolicy.canUpdate(id) + if ( + payload.avatarUrl != null && + !isAllowedAvatarUrl(payload.avatarUrl, Option.getOrUndefined(publicUrl), id) + ) { + return yield* Effect.fail( + new UnauthorizedError({ + message: "Invalid avatar URL", + detail: "Use an avatar uploaded for this user", + }), + ) + } const updatedUser = yield* userRepo.update({ id, ...payload, @@ -59,3 +71,23 @@ export const UserRpcLive = UserRpcs.toLayer( } }), ) + +/** Profile avatars must come from this user's upload prefix on the configured CDN. */ +export const isAllowedAvatarUrl = (value: string, publicUrl: string | undefined, userId: string): boolean => { + if (publicUrl === undefined) return false + try { + const base = new URL(publicUrl) + const avatar = new URL(value) + const prefix = `${base.pathname.replace(/\/$/, "")}/avatars/${userId}/` + return ( + (avatar.protocol === "https:" || avatar.protocol === "http:") && + avatar.origin === base.origin && + avatar.username === "" && + avatar.password === "" && + avatar.pathname.startsWith(prefix) && + avatar.pathname.length > prefix.length + ) + } catch { + return false + } +} diff --git a/apps/backend/src/rpc/middleware/auth.test.ts b/apps/backend/src/rpc/middleware/auth.test.ts index ba48e7d30..821a5e24b 100644 --- a/apps/backend/src/rpc/middleware/auth.test.ts +++ b/apps/backend/src/rpc/middleware/auth.test.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from "@effect/vitest" import { Headers } from "effect/http" import type { SuccessValue } from "effect/rpc/RpcMiddleware" import { BotRepo, UserRepo } from "@hazel/backend-core" -import { CurrentUser, type CurrentUser as CurrentUserNamespace } from "@hazel/domain" +import { CurrentUser, InvalidBearerTokenError, type CurrentUser as CurrentUserNamespace } from "@hazel/domain" import type { UserId } from "@hazel/schema" import { Effect, Layer, Option, Ref, Result, Context } from "effect" import { AuthMiddleware, AuthMiddlewareLive } from "./auth.ts" +import { HazelSession } from "../../services/hazel-session.ts" import { SessionManager } from "../../services/session-manager.ts" import { serviceShape } from "../../test/effect-helpers" @@ -73,6 +74,19 @@ const makeSessionManagerLayer = (currentUser: CurrentUserNamespace.Schema) => }), ) +type HazelSessionShape = Context.Service.Shape + +const makeHazelSessionLayer = (overrides: Partial = {}) => + Layer.succeed( + HazelSession, + serviceShape({ + enabled: false, + verify: () => Effect.succeedNone, + fromCookie: () => Effect.succeedNone, + ...overrides, + }), + ) + const makeBotRepoLayer = (findByTokenHash: BotRepoShape["findByTokenHash"]) => Layer.succeed( BotRepo, @@ -119,6 +133,8 @@ const USER_RECORD: UserRecord = { settings: null, isOnboarded: true, timezone: "UTC", + authActive: true, + securityRevision: "initial", createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), deletedAt: null, @@ -128,6 +144,7 @@ const runAuth = ( headers: Headers.Headers, overrides: { sessionManager?: ReturnType + hazelSession?: ReturnType botRepo?: ReturnType userRepo?: ReturnType } = {}, @@ -137,6 +154,7 @@ const runAuth = ( invokeMiddleware(headers).pipe( Effect.provide(AuthMiddlewareLive), Effect.provide(overrides.sessionManager ?? makeSessionManagerLayer(makeCurrentUser())), + Effect.provide(overrides.hazelSession ?? makeHazelSessionLayer()), Effect.provide( overrides.botRepo ?? makeBotRepoLayer(() => Effect.succeed(Option.none())), ), @@ -215,4 +233,62 @@ describe("AuthMiddlewareLive", () => { expect(failureTag).toBe("InvalidBearerTokenError") } }) + + it("authenticates a Hazel session cookie before any bearer token", async () => { + const currentUser = makeCurrentUser({ email: "cookie@example.com" }) + const result = await runAuth(Headers.fromInput({ authorization: "Bearer a.b.c" }), { + hazelSession: makeHazelSessionLayer({ + enabled: true, + fromCookie: () => Effect.succeed(Option.some(currentUser)), + }), + }) + + expect(Result.isSuccess(result) && Option.getOrNull(result.success)?.email).toBe("cookie@example.com") + }) + + it("authenticates a Hazel session credential sent as a bearer token", async () => { + const currentUser = makeCurrentUser({ email: "native@example.com" }) + const result = await runAuth(Headers.fromInput({ authorization: "Bearer session-credential" }), { + hazelSession: makeHazelSessionLayer({ + enabled: true, + verify: (credential) => + Effect.succeed( + credential === "session-credential" ? Option.some(currentUser) : Option.none(), + ), + }), + }) + + expect(Result.isSuccess(result) && Option.getOrNull(result.success)?.email).toBe("native@example.com") + }) + + it("falls through a stale cookie to a valid bearer, but rejects a stale cookie alone", async () => { + const hazelSession = makeHazelSessionLayer({ + enabled: true, + fromCookie: () => + Effect.fail(new InvalidBearerTokenError({ message: "expired", detail: "expired" })), + }) + const valid = await runAuth(Headers.fromInput({ authorization: "Bearer a.b.c" }), { hazelSession }) + expect(Result.isSuccess(valid)).toBe(true) + const cookieOnly = await runAuth(Headers.fromInput({}), { hazelSession }) + expect( + Result.isFailure(cookieOnly) && + ("_tag" in cookieOnly.failure ? cookieOnly.failure._tag : "unhandled"), + ).toBe("InvalidBearerTokenError") + }) + + it("never checks bot tokens as Hazel sessions", async () => { + let checked = false + const result = await runAuth(Headers.fromInput({ authorization: "Bearer hzl_bot_abc" }), { + hazelSession: makeHazelSessionLayer({ + enabled: true, + verify: () => { + checked = true + return Effect.succeedNone + }, + }), + }) + + expect(checked).toBe(false) + expect(Result.isFailure(result)).toBe(true) + }) }) diff --git a/apps/backend/src/rpc/middleware/auth.ts b/apps/backend/src/rpc/middleware/auth.ts index f776aefb1..c97f1877b 100644 --- a/apps/backend/src/rpc/middleware/auth.ts +++ b/apps/backend/src/rpc/middleware/auth.ts @@ -4,6 +4,7 @@ import { CurrentUser, InvalidBearerTokenError, SessionNotProvidedError } from "@ import { Effect, Layer, Option } from "effect" import { AuthMiddleware } from "@hazel/domain/rpc" import { type ApiScope, CurrentBotScopes } from "@hazel/domain/scopes" +import { HazelSession } from "../../services/hazel-session" import { SessionManager } from "../../services/session-manager" export { AuthMiddleware } from "@hazel/domain/rpc" @@ -31,14 +32,30 @@ export const AuthMiddlewareLive = Layer.effect( AuthMiddleware, Effect.gen(function* () { const sessionManager = yield* SessionManager + const hazelSession = yield* HazelSession const botRepo = yield* BotRepo const userRepo = yield* UserRepo return AuthMiddleware.of((effect, { headers }) => Effect.gen(function* () { - // Check for Bearer token first (bot SDK or desktop app authentication) + // Hazel session cookie (browser). RPC calls are POSTs, so the Origin is checked. const authHeader = Headers.get(headers, "authorization") + const hasBearer = + Option.isSome(authHeader) && + authHeader.value.startsWith("Bearer ") && + authHeader.value.length > 7 + const cookieUser = yield* hazelSession + .fromCookie(headers, "POST") + .pipe( + Effect.catchTag("InvalidBearerTokenError", (error) => + hasBearer ? Effect.succeedNone : Effect.fail(error), + ), + ) + if (Option.isSome(cookieUser)) { + return yield* Effect.provideService(effect, CurrentUser.Context, cookieUser.value) + } + // Bearer token: Clerk JWT, Hazel session credential (native clients) or bot token if (Option.isSome(authHeader) && authHeader.value.startsWith("Bearer ")) { const token = authHeader.value.slice(7) @@ -48,6 +65,17 @@ export const AuthMiddlewareLive = Layer.effect( return yield* Effect.provideService(effect, CurrentUser.Context, currentUser) } + if (hazelSession.enabled && !token.startsWith("hzl_bot_")) { + const sessionUser = yield* hazelSession.verify(token) + if (Option.isSome(sessionUser)) { + return yield* Effect.provideService( + effect, + CurrentUser.Context, + sessionUser.value, + ) + } + } + // Otherwise, treat as bot token (hash-based lookup) const tokenHash = yield* Effect.promise(() => hashToken(token)) diff --git a/apps/backend/src/rpc/server.ts b/apps/backend/src/rpc/server.ts index 84a0a6b40..f0ff4b069 100644 --- a/apps/backend/src/rpc/server.ts +++ b/apps/backend/src/rpc/server.ts @@ -9,6 +9,7 @@ import { ChannelWebhookRpcs, ConnectShareRpcs, CustomEmojiRpcs, + InvitationRpcs, GitHubSubscriptionRpcs, IntegrationRequestRpcs, RssSubscriptionRpcs, @@ -33,6 +34,7 @@ import { ChannelWebhookRpcLive } from "./handlers/channel-webhooks" import { ChannelRpcLive } from "./handlers/channels" import { ConnectShareRpcLive } from "./handlers/connect-shares" import { CustomEmojiRpcLive } from "./handlers/custom-emojis" +import { InvitationRpcLive } from "./handlers/invitations" import { GitHubSubscriptionRpcLive } from "./handlers/github-subscriptions" import { IntegrationRequestRpcLive } from "./handlers/integration-requests" import { RssSubscriptionRpcLive } from "./handlers/rss-subscriptions" @@ -83,6 +85,7 @@ const BaseRpcs = MessageRpcs.merge( AttachmentRpcs, BotRpcs, CustomEmojiRpcs, + InvitationRpcs, ) export const AllRpcs = BaseRpcs.merge(ChatSyncRpcs).middleware(ScopeInjectionMiddleware) @@ -109,6 +112,7 @@ const rpcGroups = [ ["AttachmentRpcs", AttachmentRpcs], ["BotRpcs", BotRpcs], ["CustomEmojiRpcs", CustomEmojiRpcs], + ["InvitationRpcs", InvitationRpcs], ["ChatSyncRpcs", ChatSyncRpcs], ] as const @@ -146,5 +150,6 @@ export const RpcServerLive = Layer.empty Layer.provideMerge(AttachmentRpcLive), Layer.provideMerge(BotRpcLive), Layer.provideMerge(CustomEmojiRpcLive), + Layer.provideMerge(InvitationRpcLive), ) .pipe(Layer.provideMerge(AuthMiddlewareLive), Layer.provideMerge(ScopeInjectionMiddlewareLive)) diff --git a/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.test.ts b/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.test.ts new file mode 100644 index 000000000..44f0e6b25 --- /dev/null +++ b/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.test.ts @@ -0,0 +1,48 @@ +import { PgClient } from "@effect/sql-pg" +import { CryptoLive, identityKey, linkIdentity } from "@hazel/auth/server" +import { Effect, Redacted } from "effect" +import { SqlClient } from "effect/sql" +import type { User } from "@clerk/backend" +import { expect, it } from "vitest" +import { importIdentity, providerAccounts } from "./import-clerk-identities" + +it("imports only explicitly verified GitHub or Google accounts", () => { + const user = { + externalAccounts: [ + { provider: "oauth_github", providerUserId: "1", verification: null }, + { provider: "oauth_google", providerUserId: "2", verification: { status: "failed" } }, + { provider: "oauth_github", providerUserId: "3", verification: { status: "verified" } }, + ], + } as unknown as User + expect(providerAccounts(user)).toEqual([{ provider: "github", subject: "3" }]) +}) + +it.skipIf(!process.env.AUTH_TEST_DATABASE_URL)( + "dry runs report ownership conflicts and never insert available identities", + async () => { + const original = "00000000-0000-4000-8000-000000000111" + const candidate = "00000000-0000-4000-8000-000000000112" + const account = { provider: "github" as const, subject: crypto.randomUUID() } + const unlinked = { provider: "google" as const, subject: crypto.randomUUID(), userId: candidate } + const result = await Effect.runPromise( + Effect.gen(function* () { + yield* linkIdentity({ ...account, userId: original }) + const conflict = yield* importIdentity({ ...account, userId: candidate }, false) + const available = yield* importIdentity(unlinked, false) + const sql = yield* SqlClient.SqlClient + const key = yield* identityKey(unlinked) + const rows = + yield* sql`select "identityKey" from auth_oauth_identities where "identityKey" = ${key}` + return { conflict, available, rows } + }).pipe( + Effect.provide( + PgClient.layer({ url: Redacted.make(process.env.AUTH_TEST_DATABASE_URL!), ssl: false }), + ), + Effect.provide(CryptoLive), + ), + ) + expect(result.conflict).toEqual({ _tag: "Conflict", ownerId: original }) + expect(result.available._tag).toBe("Available") + expect(result.rows).toHaveLength(0) + }, +) diff --git a/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts b/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts new file mode 100644 index 000000000..81caf6b6c --- /dev/null +++ b/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts @@ -0,0 +1,180 @@ +/** + * Carries users over from Clerk to Hazel sign-in: links each user's GitHub and Google + * accounts (as recorded by Clerk) to their existing Hazel user, so their first sign-in on + * the new system lands in their own account. + * + * Dry run by default: reads Clerk and the database, writes nothing, and prints how users + * split up. `--apply` writes the links. Safe to re-run (linking is idempotent), so it can + * run nightly until the cutover to pick up new Clerk sign-ups. + * + * Usage: + * CLERK_SECRET_KEY=… DATABASE_URL=… bun run src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts + * CLERK_SECRET_KEY=… DATABASE_URL=… bun run src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts --apply + * + * Output: a summary, and ./exports/hazel-auth-import-report.json with the users in each + * group (it contains emails: keep it local). + * + * Groups: + * - linked: has GitHub or Google in Clerk. Imported; signs straight in. + * - emailOnly: no GitHub or Google. Signs in with GitHub or Google whose verified email + * matches theirs (linked automatically at sign-in), unless the email is shared. + * - emailShared: email-only and another active user has the same email, so sign-in can't + * match it automatically. Needs a manual link. + * - conflicts: the provider account is already linked to a different Hazel user. + * - missingInHazel / missingInClerk: present on one side only. + */ +import { chmodSync, closeSync, fchmodSync, mkdirSync, openSync, writeFileSync } from "node:fs" +import { createClerkClient, type User as ClerkUser } from "@clerk/backend" +import { PgClient } from "@effect/sql-pg" +import { CryptoLive, identityKey, isProvider, linkIdentity, type Provider } from "@hazel/auth/server" +import { Config, Effect, Redacted } from "effect" +import { SqlClient } from "effect/sql" + +const apply = process.argv.includes("--apply") +const REPORT_PATH = "./exports/hazel-auth-import-report.json" +const PAGE_SIZE = 500 + +type HazelUser = { id: string; externalId: string; email: string; deleted: boolean; userType: string } +type Entry = { hazelUserId?: string; clerkUserId?: string; email?: string; detail?: string } + +const listClerkUsers = (secretKey: string) => + Effect.tryPromise(async () => { + const clerk = createClerkClient({ secretKey }) + const users: Array = [] + for (let offset = 0; ; offset += PAGE_SIZE) { + const page = await clerk.users.getUserList({ limit: PAGE_SIZE, offset, orderBy: "+created_at" }) + users.push(...page.data) + if (page.data.length < PAGE_SIZE) return users + } + }) + +/** Verified GitHub and Google accounts; Clerk names providers `oauth_github` / `oauth_google`. */ +export const providerAccounts = (user: ClerkUser) => + user.externalAccounts.flatMap((account) => { + const provider = account.provider.replace(/^oauth_/, "") + if (!isProvider(provider) || account.providerUserId === "") return [] + if (account.verification?.status !== "verified") return [] + return [{ provider: provider as Provider, subject: account.providerUserId }] + }) + +/** The dry-run path checks ownership without calling the writer. Apply still rechecks atomically. */ +export const importIdentity = ( + account: { provider: Provider; subject: string; userId: string }, + apply: boolean, +) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const key = yield* identityKey(account) + const [owner] = yield* sql<{ + subjectId: string + }>`select "subjectId"::text as "subjectId" from auth_oauth_identities where "identityKey" = ${key}` + if (owner !== undefined && owner.subjectId !== account.userId) + return { _tag: "Conflict" as const, ownerId: owner.subjectId } + if (apply) return yield* linkIdentity(account) + return owner === undefined ? { _tag: "Available" as const } : { _tag: "AlreadyLinked" as const } + }) + +const primaryEmail = (user: ClerkUser) => + user.emailAddresses.find((address) => address.id === user.primaryEmailAddressId)?.emailAddress + +const program = Effect.gen(function* () { + const secretKey = yield* Config.Redacted("CLERK_SECRET_KEY") + const sql = yield* SqlClient.SqlClient + + const hazelUsers = yield* sql` + select id::text as id, "externalId", email, "deletedAt" is not null as deleted, "userType" + from users` + const byClerkId = new Map(hazelUsers.map((user) => [user.externalId, user])) + const activeByEmail = new Map() + for (const user of hazelUsers) { + if (user.deleted || user.userType !== "user") continue + const email = user.email.toLowerCase() + activeByEmail.set(email, (activeByEmail.get(email) ?? 0) + 1) + } + + const clerkUsers = yield* listClerkUsers(Redacted.value(secretKey)) + yield* Effect.log(`Clerk users: ${clerkUsers.length}. Hazel users: ${hazelUsers.length}.`) + + const report = { + linked: [] as Array, + emailOnly: [] as Array, + emailShared: [] as Array, + conflicts: [] as Array, + missingInHazel: [] as Array, + missingInClerk: [] as Array, + } + let identitiesLinked = 0 + let identitiesAlreadyLinked = 0 + + for (const clerkUser of clerkUsers) { + const email = primaryEmail(clerkUser) + const hazel = byClerkId.get(clerkUser.id) + if (hazel === undefined) { + report.missingInHazel.push({ clerkUserId: clerkUser.id, ...(email ? { email } : {}) }) + continue + } + if (hazel.deleted || hazel.userType !== "user") continue + const entry: Entry = { hazelUserId: hazel.id, clerkUserId: clerkUser.id, email: hazel.email } + + const accounts = providerAccounts(clerkUser) + if (accounts.length === 0) { + const shared = (activeByEmail.get(hazel.email.toLowerCase()) ?? 0) > 1 + ;(shared ? report.emailShared : report.emailOnly).push(entry) + continue + } + + let hasConflict = false + for (const account of accounts) { + const result = yield* importIdentity({ ...account, userId: hazel.id }, apply) + if (result._tag === "Conflict") { + hasConflict = true + report.conflicts.push({ + ...entry, + detail: `${account.provider} account already belongs to Hazel user ${result.ownerId}`, + }) + } else if (apply && result._tag === "Linked") identitiesLinked++ + else if (apply) identitiesAlreadyLinked++ + } + if (!hasConflict) + report.linked.push({ ...entry, detail: accounts.map((account) => account.provider).join("+") }) + } + + const clerkIds = new Set(clerkUsers.map((user) => user.id)) + for (const user of hazelUsers) { + if (user.deleted || user.userType !== "user" || clerkIds.has(user.externalId)) continue + // Users created by Hazel sign-in itself are not in Clerk by design. + if (user.externalId.startsWith("hazel:")) continue + report.missingInClerk.push({ hazelUserId: user.id, email: user.email, detail: user.externalId }) + } + + mkdirSync("./exports", { recursive: true, mode: 0o700 }) + chmodSync("./exports", 0o700) + const reportFile = openSync(REPORT_PATH, "w", 0o600) + try { + fchmodSync(reportFile, 0o600) + writeFileSync(reportFile, JSON.stringify(report, null, "\t")) + } finally { + closeSync(reportFile) + } + + yield* Effect.log(apply ? "Applied." : "Dry run: nothing written. Re-run with --apply to link.") + yield* Effect.log(` linked (GitHub/Google): ${report.linked.length}`) + yield* Effect.log(` email only: ${report.emailOnly.length}`) + yield* Effect.log(` email shared (manual): ${report.emailShared.length}`) + yield* Effect.log(` missing in Hazel: ${report.missingInHazel.length}`) + yield* Effect.log(` missing in Clerk: ${report.missingInClerk.length}`) + if (apply) { + yield* Effect.log(` identities linked now: ${identitiesLinked}`) + yield* Effect.log(` already linked: ${identitiesAlreadyLinked}`) + } + yield* Effect.log(` conflicts (manual): ${report.conflicts.length}`) + yield* Effect.log(`Report: ${REPORT_PATH}`) +}) + +const SqlLive = PgClient.layerConfig({ url: Config.Redacted("DATABASE_URL") }) + +if (import.meta.main) + Effect.runPromise(program.pipe(Effect.provide(SqlLive), Effect.provide(CryptoLive))).catch((error) => { + console.error(error) + process.exit(1) + }) diff --git a/apps/backend/src/services/auth.ts b/apps/backend/src/services/auth.ts index 14f4c172b..a8ed7a6c8 100644 --- a/apps/backend/src/services/auth.ts +++ b/apps/backend/src/services/auth.ts @@ -1,21 +1,54 @@ import { CurrentUser } from "@hazel/domain" -import { Effect, Layer, Redacted } from "effect" +import { Effect, Layer, Option, Redacted } from "effect" +import { HttpServerRequest } from "effect/http" +import { HazelSession } from "./hazel-session" import { SessionManager } from "./session-manager" +/** Three base64url segments: a Clerk session JWT rather than a Hazel session credential. */ +const isJwt = (token: string) => { + const parts = token.split(".") + return parts.length === 3 && parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part)) +} + export const AuthorizationLive = Layer.effect( CurrentUser.Authorization, Effect.gen(function* () { yield* Effect.logDebug("Initializing Authorization middleware...") const sessionManager = yield* SessionManager + const hazelSession = yield* HazelSession return CurrentUser.Authorization.of({ bearer: (httpEffect, { credential: bearerToken }) => Effect.gen(function* () { - yield* Effect.logDebug("checking bearer token") + // Hazel session cookie (browser) + const request = yield* HttpServerRequest.HttpServerRequest + const token = Redacted.value(bearerToken) + const cookieUser = yield* hazelSession + .fromCookie(request.headers, request.method) + .pipe( + Effect.catchTag("InvalidBearerTokenError", (error) => + token !== "" ? Effect.succeedNone : Effect.fail(error), + ), + ) + if (Option.isSome(cookieUser)) { + return yield* Effect.provideService(httpEffect, CurrentUser.Context, cookieUser.value) + } + + // Hazel session credential (native clients) + if (hazelSession.enabled && token !== "" && !isJwt(token)) { + const sessionUser = yield* hazelSession.verify(token) + if (Option.isSome(sessionUser)) { + return yield* Effect.provideService( + httpEffect, + CurrentUser.Context, + sessionUser.value, + ) + } + } - // Use SessionManager to handle bearer token authentication - const user = yield* sessionManager.authenticateWithBearer(Redacted.value(bearerToken)) + // Clerk session JWT + const user = yield* sessionManager.authenticateWithBearer(token) return yield* Effect.provideService(httpEffect, CurrentUser.Context, user) }), }) diff --git a/apps/backend/src/services/hazel-auth-config.test.ts b/apps/backend/src/services/hazel-auth-config.test.ts new file mode 100644 index 000000000..5128dece6 --- /dev/null +++ b/apps/backend/src/services/hazel-auth-config.test.ts @@ -0,0 +1,30 @@ +import { ConfigProvider, Effect, Exit } from "effect" +import { expect, it } from "vitest" +import { HazelAuthInstance } from "./hazel-auth" + +it("reports a malformed signing key without retaining its input", async () => { + const config = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + API_BASE_URL: "https://api.hazel.test", + FRONTEND_URL: "https://app.hazel.test", + AUTH_GITHUB_CLIENT_ID: "test-client", + AUTH_GITHUB_CLIENT_SECRET: "test-secret", + AUTH_BINDING_KEY: "test-binding", + AUTH_TRANSACTION_KEY: "test-transaction", + AUTH_ACCESS_TOKEN_PRIVATE_JWK: "private-input-that-must-not-appear", + }), + ) + const exit = await Effect.runPromiseExit( + Effect.service(HazelAuthInstance).pipe( + Effect.provide(HazelAuthInstance.layer), + Effect.provide(config), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect( + exit.cause.reasons.map((reason) => ("error" in reason ? reason.error._tag : reason._tag)), + ).toEqual(["AccessTokenConfigurationError"]) + } + expect(JSON.stringify(exit)).not.toContain("private-input-that-must-not-appear") +}) diff --git a/apps/backend/src/services/hazel-auth.test.ts b/apps/backend/src/services/hazel-auth.test.ts new file mode 100644 index 000000000..537bdd99c --- /dev/null +++ b/apps/backend/src/services/hazel-auth.test.ts @@ -0,0 +1,322 @@ +/** + * The backend's Hazel sign-in wiring end to end: env config, the `/auth/*` and token + * routes, and both auth middlewares (RPC and HttpApi) resolving the session cookie to the + * Hazel user. Runs against a disposable Postgres container with the schema pushed from + * packages/db. Only GitHub is simulated. + */ +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform" +import * as NodeServices from "@effect/platform-node/NodeServices" +import { AccessToken, CryptoLive, layerPool } from "@hazel/auth/server" +import { BotRepo, UserRepo } from "@hazel/backend-core" +import { Database, PG, schema } from "@hazel/db" +import { CurrentUser } from "@hazel/domain" +import { AuthMiddleware } from "@hazel/domain/rpc" +import { ConfigProvider, Effect, Layer, Redacted } from "effect" +import { Base64Url } from "effect/encoding" +import { + Etag, + HttpClient, + HttpClientResponse, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "effect/http" +import type { SuccessValue } from "effect/rpc/RpcMiddleware" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { AuthMiddlewareLive } from "../rpc/middleware/auth" +import { createChatSyncDbHarness, type ChatSyncDbHarness } from "../test/chat-sync-db-harness" +import { serviceShape } from "../test/effect-helpers" +import { AuthorizationLive } from "./auth" +import { HazelAuthInstance, HazelAuthRoutes, HazelAuthServicesLive } from "./hazel-auth" +import { HazelSession } from "./hazel-session" +import { SessionManager } from "./session-manager" + +const api = "http://localhost:3003" +const app = "http://localhost:3000" +const key = Base64Url.encode(new Uint8Array(32).fill(7)) + +/** The GitHub account the fake signs in as. */ +let github = { id: 777, email: "octo@example.com" } + +const FakeGitHub = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + const json = (body: unknown) => + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }), + ) + if (url.href === "https://github.com/login/oauth/access_token") + return json({ access_token: "gho_1", token_type: "bearer", scope: "read:user,user:email" }) + if (url.href === "https://api.github.com/user") + return json({ + id: github.id, + login: `octo${github.id}`, + name: "Octo Cat", + email: null, + avatar_url: `https://avatars.githubusercontent.com/u/${github.id}`, + }) + if (url.href.startsWith("https://api.github.com/user/emails")) + return json([{ email: github.email, primary: true, verified: true }]) + return HttpClientResponse.fromWeb(request, new Response("not found", { status: 404 })) + }), + ), +) + +/** Clerk is not involved: any Clerk path reaching it is a test failure. */ +const NoClerk = Layer.succeed( + SessionManager, + serviceShape({ + authenticateWithBearer: () => Effect.die(new Error("Clerk should not be consulted")), + }), +) + +const successValue = { _: Symbol("success") } as SuccessValue + +const tagOf = (error: unknown) => + typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : "unknown" + +/** Stand-ins for an RPC call and an HttpApi endpoint, each behind the real middleware. */ +const ProbeRoutes = Layer.unwrap( + Effect.gen(function* () { + const rpcAuth = yield* AuthMiddleware + const apiAuth = yield* CurrentUser.Authorization + const whoAmI = Effect.map(Effect.service(CurrentUser.Context), (user) => user.email) + + return Layer.mergeAll( + HttpRouter.add( + "POST", + "/probe/rpc", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + let email = "" + yield* rpcAuth( + Effect.map(whoAmI, (value) => { + email = value + return successValue + }), + { + client: {} as never, + requestId: 1n as never, + rpc: {} as never, + payload: undefined, + headers: request.headers, + }, + ) + return yield* HttpServerResponse.json({ email }) + }).pipe( + Effect.catch((error) => + HttpServerResponse.json({ error: tagOf(error) }, { status: 401 }), + ), + ), + ), + HttpRouter.add( + "GET", + "/probe/api", + apiAuth + .bearer( + Effect.flatMap(whoAmI, (email) => + HttpServerResponse.json({ email }).pipe(Effect.orDie), + ), + { credential: Redacted.make(""), endpoint: {} as never, group: {} as never }, + ) + .pipe( + Effect.catch((error) => + HttpServerResponse.json({ error: tagOf(error) }, { status: 401 }), + ), + ), + ), + ) + }), +) + +describe("Hazel sign-in in the backend", () => { + let harness: ChatSyncDbHarness + let web: ReturnType + + beforeAll(async () => { + harness = await createChatSyncDbHarness(process.env.AUTH_TEST_DATABASE_URL) + const url = Redacted.make(harness.container.getConnectionUri()) + + const pair = await Effect.runPromise( + AccessToken.generateSigningKey("test").pipe(Effect.provide(CryptoLive)), + ) + const env = ConfigProvider.fromUnknown({ + API_BASE_URL: api, + FRONTEND_URL: app, + AUTH_GITHUB_CLIENT_ID: "Iv1.test", + AUTH_GITHUB_CLIENT_SECRET: "secret", + AUTH_BINDING_KEY: key, + AUTH_TRANSACTION_KEY: key, + AUTH_ACCESS_TOKEN_KID: "test", + AUTH_ACCESS_TOKEN_PRIVATE_JWK: JSON.stringify(Redacted.value(pair.privateKey)), + }) + + // As in app.ts: AppAuthorizationLive's pieces, under the entry point's platform. + const Auth = Layer.mergeAll(AuthMiddlewareLive, AuthorizationLive).pipe( + Layer.provideMerge(HazelSession.layer), + Layer.provideMerge(HazelAuthServicesLive), + Layer.provideMerge(HazelAuthInstance.layer), + Layer.provide(NoClerk), + ) + + web = HttpRouter.toWebHandler( + Layer.mergeAll(HazelAuthRoutes, ProbeRoutes).pipe( + Layer.provide(Auth), + Layer.provide([UserRepo.layer, BotRepo.layer]), + Layer.provide(harness.dbLayer), + Layer.provide(layerPool(url)), + Layer.provide(FakeGitHub), + Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layer]), + Layer.provide(ConfigProvider.layer(env)), + ) as Layer.Layer, + { disableLogger: !process.env.DEBUG_AUTH }, + ) + }, 180_000) + + afterAll(async () => { + await web?.dispose() + await harness?.stop() + }, 60_000) + + const cookies = new Map() + const cookieHeader = () => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ") + const send = async (path: string, init: RequestInit & { origin?: string | null } = {}) => { + const headers = new Headers(init.headers) + if (init.origin !== null) headers.set("origin", init.origin ?? app) + headers.set("cookie", cookieHeader()) + const response = await (web.handler as (request: Request) => Promise)( + new Request(`${api}${path}`, { ...init, headers, redirect: "manual" }), + ) + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(";") + const index = pair!.indexOf("=") + const [name, value] = [pair!.slice(0, index), pair!.slice(index + 1)] + if (value === "" || /max-age=0/i.test(raw)) cookies.delete(name) + else cookies.set(name, value) + } + return response + } + const post = (path: string, payload: unknown = {}) => + send(path, { + method: "POST", + headers: { "content-type": "application/json", "x-effect-auth-csrf": "1" }, + body: JSON.stringify({ payload }), + }) + + it("signs up with GitHub and is recognised by the RPC and HttpApi middleware", async () => { + const begin = await post("/auth/signIn", { provider: "github", returnTarget: "/" }) + expect(begin.status).toBe(200) + const state = new URL((await begin.json()).value.authorizationUrl).searchParams.get("state")! + + const callback = await send( + `/auth/github/callback?${new URLSearchParams({ code: "c", state, iss: "https://github.com/login/oauth" })}`, + { origin: null }, + ) + expect(callback.status).toBe(303) + const registration = new URL(callback.headers.get("location")!) + expect(`${registration.origin}${registration.pathname}`).toBe(`${app}/auth/register`) + + const registered = await post("/auth/register", { + reference: registration.searchParams.get("reference"), + flowId: registration.searchParams.get("flowId"), + commandId: crypto.randomUUID(), + registration: { firstName: "Octo", lastName: "Cat" }, + }) + expect((await registered.json()).value._tag).toBe("Authenticated") + expect(cookies.has("effect-auth-session")).toBe(true) + + const rpc = await send("/probe/rpc", { method: "POST" }) + expect(await rpc.json()).toEqual({ email: "octo@example.com" }) + + const httpApi = await send("/probe/api") + expect(await httpApi.json()).toEqual({ email: "octo@example.com" }) + }, 60_000) + + it("rejects a cookie session sent from an untrusted origin", async () => { + const response = await send("/probe/rpc", { method: "POST", origin: "https://evil.example" }) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: "InvalidBearerTokenError" }) + }) + + it("accepts the session credential as a bearer token (native clients)", async () => { + const credential = cookies.get("effect-auth-session")! + const saved = new Map(cookies) + cookies.clear() + try { + const response = await send("/probe/rpc", { + method: "POST", + origin: null, + headers: { authorization: `Bearer ${credential}` }, + }) + expect(await response.json()).toEqual({ email: "octo@example.com" }) + } finally { + for (const [name, value] of saved) cookies.set(name, value) + } + }) + + it("exchanges the session for an access token that verifies against the JWKS", async () => { + const minted = await post("/auth/token") + expect(minted.status).toBe(200) + const { token } = await minted.json() + const jwks = await (await send("/.well-known/jwks.json")).json() + + const verified = await Effect.runPromise( + AccessToken.verifyAccessToken(api, token).pipe( + Effect.provide(AccessToken.Jwks.layerLocal(jwks)), + Effect.provide(CryptoLive), + ), + ) + const me = await (await send("/probe/api")).json() + expect(me.email).toBe("octo@example.com") + expect(verified.claims.iss).toBe(api) + }) + + it("signing out ends the session for the middleware too", async () => { + expect((await post("/auth/signOut")).status).toBe(200) + const response = await send("/probe/rpc", { method: "POST" }) + expect(response.status).toBe(401) + expect(cookies.has("effect-auth-session")).toBe(false) + }) + + it("a Clerk user's first GitHub sign-in lands in their existing account by verified email", async () => { + // Signed up through Clerk with email: a users row and no linked accounts. + const [existing] = await harness.run( + Effect.gen(function* () { + const db = yield* Database.Database + return yield* db.run( + PG.insertInto(schema.usersTable) + .values({ + externalId: "user_clerk_email_only", + email: "Clerk.User@Example.com", + firstName: "Clerk", + lastName: "User", + isOnboarded: true, + }) + .returning("id"), + ) + }), + ) + github = { id: 888, email: "clerk.user@example.com" } + + const signIn = async () => { + const begin = await post("/auth/signIn", { provider: "github", returnTarget: "/" }) + const state = new URL((await begin.json()).value.authorizationUrl).searchParams.get("state")! + const callback = await send( + `/auth/github/callback?${new URLSearchParams({ code: "c", state, iss: "https://github.com/login/oauth" })}`, + { origin: null }, + ) + return new URL(callback.headers.get("location")!) + } + + const first = await signIn() + expect(`${first.origin}${first.pathname}`).toBe(`${app}/auth/continue`) + expect(first.searchParams.get("provider")).toBe("github") + + expect((await signIn()).href).toBe(`${app}/`) + const rpc = await send("/probe/rpc", { method: "POST" }) + expect(await rpc.json()).toEqual({ email: "Clerk.User@Example.com" }) + expect(existing).toBeDefined() + }, 60_000) +}) diff --git a/apps/backend/src/services/hazel-auth.ts b/apps/backend/src/services/hazel-auth.ts new file mode 100644 index 000000000..cd6c136d9 --- /dev/null +++ b/apps/backend/src/services/hazel-auth.ts @@ -0,0 +1,197 @@ +import { Hooks } from "@yielded/auth" +import { + AccessToken, + HazelAuth, + type HazelAuthConfig, + makeDesktopLoginRoutes, + makeHazelAuthLive, +} from "@hazel/auth/server" +import { Config, Context, Effect, Layer, Option, Redacted, Schema } from "effect" +import { Headers, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http" + +/** + * Hazel's own sign-in (`@hazel/auth/server`), replacing Clerk. Enabled when + * `AUTH_GITHUB_CLIENT_ID` is set; until then Clerk remains the only sign-in and these + * routes are not mounted. + * + * - `/auth/*`: sign-in, registration, passkeys and sessions (cookie on the API host). + * - `POST /auth/token`: exchanges the session for a short-lived JWT for the hosts that + * cannot see the API's cookie (Electric proxy, actors), when a signing key is set. + * - `GET /.well-known/jwks.json`: the public key those hosts verify it with. + */ + +const keyring = (name: string) => + Config.Redacted(name).pipe( + Config.map((material) => ({ activeKeyId: "v1", keys: [{ id: "v1", material }] })), + ) + +const origins = (name: string) => + Config.String(name).pipe( + Config.map((value) => + value + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin !== ""), + ), + Config.withDefault([] as Array), + ) + +const AuthConfig = Config.all({ + githubClientId: Config.option(Config.String("AUTH_GITHUB_CLIENT_ID")), + apiBaseUrl: Config.String("API_BASE_URL"), + frontendUrl: Config.String("FRONTEND_URL"), + extraOrigins: origins("AUTH_TRUSTED_ORIGINS"), + passkeyRpId: Config.option(Config.String("AUTH_PASSKEY_RP_ID")), + googleClientId: Config.option(Config.String("AUTH_GOOGLE_CLIENT_ID")), + accessTokenKid: Config.String("AUTH_ACCESS_TOKEN_KID").pipe(Config.withDefault("v1")), + accessTokenJwk: Config.option(Config.Redacted("AUTH_ACCESS_TOKEN_PRIVATE_JWK")), +}) + +const PrivateJwk = Schema.fromJsonString(Schema.Unknown) + +const make = Effect.gen(function* () { + const env = yield* AuthConfig + if (Option.isNone(env.githubClientId)) { + yield* Effect.logInfo("[auth] AUTH_GITHUB_CLIENT_ID is not set; Hazel sign-in is disabled") + return Option.none() + } + + const origin = new URL(env.apiBaseUrl).origin + const appOrigin = new URL(env.frontendUrl).origin + const trustedOrigins = [...new Set([appOrigin, ...env.extraOrigins])] + + const google = Option.isSome(env.googleClientId) + ? { + clientId: env.googleClientId.value, + clientSecret: yield* Config.Redacted("AUTH_GOOGLE_CLIENT_SECRET"), + } + : undefined + + const config: HazelAuthConfig = { + origin, + appOrigin, + trustedOrigins, + github: { + clientId: env.githubClientId.value, + clientSecret: yield* Config.Redacted("AUTH_GITHUB_CLIENT_SECRET"), + }, + ...(google === undefined ? {} : { google }), + passkey: { + rpId: Option.getOrElse(env.passkeyRpId, () => new URL(appOrigin).hostname), + origins: trustedOrigins, + }, + keys: { + binding: yield* keyring("AUTH_BINDING_KEY"), + transaction: yield* keyring("AUTH_TRANSACTION_KEY"), + }, + returnTargets: ["/"], + registrationPath: "/auth/register", + continuePath: "/auth/continue", + } + + const accessTokens = Option.isNone(env.accessTokenJwk) + ? Option.none() + : Option.some({ + kid: env.accessTokenKid, + privateJwk: Redacted.make( + yield* Schema.decodeEffect(PrivateJwk)(Redacted.value(env.accessTokenJwk.value)).pipe( + Effect.mapError( + () => + new AccessToken.AccessTokenConfigurationError({ + key: "AUTH_ACCESS_TOKEN_PRIVATE_JWK", + message: "Expected valid JSON", + }), + ), + ), + ), + }) + + return Option.some({ ...makeHazelAuthLive(config), config, accessTokens }) +}) + +/** + * The configured auth instance, shared by the routes (`HazelAuthRoutes`) and the services + * every request handler can use (`HazelAuthServicesLive`) so both come from one `Http.make`. + */ +export class HazelAuthInstance extends Context.Service()( + "@hazel/backend/HazelAuthInstance", + { + make, + }, +) { + static readonly layer = Layer.effect(this, this.make) +} + +/** `HazelAuth` and its storage, for the RPC and HttpApi auth middleware. Empty when disabled. */ +export const HazelAuthServicesLive = Layer.unwrap( + Effect.map(Effect.service(HazelAuthInstance), (instance) => + Option.match(instance, { + onNone: () => Layer.empty, + onSome: ({ http, Services }) => + http.layer.pipe(Layer.provideMerge(Services), Layer.provide(Hooks.LifecycleHooks.empty)), + }), + ), +) + +/** Session → access token exchange, and the key set that verifies those tokens. */ +const accessTokenRoutes = (issuerOrigin: string, key: AccessToken.SigningKey) => + Layer.unwrap( + Effect.gen(function* () { + const issuer = yield* AccessToken.makeIssuer(issuerOrigin, key) + const publicJwk = yield* AccessToken.publicJwk(key) + return Layer.mergeAll( + HttpRouter.add( + "POST", + "/auth/token", + Effect.gen(function* () { + const auth = yield* HazelAuth + // The browser sends its session cookie; the desktop app its own session as a bearer. + const bearer = Option.flatMap( + Headers.get( + (yield* HttpServerRequest.HttpServerRequest).headers, + "authorization", + ), + (value) => + value.startsWith("Bearer ") ? Option.some(value.slice(7)) : Option.none(), + ) + const session = Option.isSome(bearer) + ? yield* auth + .verifySession(Redacted.make(bearer.value)) + .pipe(Effect.catchTag("SessionInvalid", () => Effect.succeed(null))) + : yield* auth.getSession() + if (session === null) return HttpServerResponse.empty({ status: 401 }) + const token = yield* issuer.mint(session) + return yield* HttpServerResponse.json({ + token: Redacted.value(token), + expiresIn: AccessToken.lifetimeSeconds, + }) + }), + ), + HttpRouter.add( + "GET", + "/.well-known/jwks.json", + HttpServerResponse.json({ keys: [publicJwk] }).pipe( + Effect.map(HttpServerResponse.setHeader("cache-control", "public, max-age=300")), + ), + ), + ) + }), + ) + +/** `/auth/*` routes. Needs `HazelAuthServicesLive` below it. Empty when disabled. */ +export const HazelAuthRoutes = Layer.unwrap( + Effect.map(Effect.service(HazelAuthInstance), (instance) => + Option.match(instance, { + onNone: () => Layer.empty, + onSome: ({ http, config, accessTokens }) => + Layer.mergeAll( + http.routes(), + makeDesktopLoginRoutes(config), + Option.match(accessTokens, { + onNone: () => Layer.empty, + onSome: (key) => accessTokenRoutes(config.origin, key).pipe(http.middleware), + }), + ), + }), + ), +) diff --git a/apps/backend/src/services/hazel-session.ts b/apps/backend/src/services/hazel-session.ts new file mode 100644 index 000000000..deb3b5677 --- /dev/null +++ b/apps/backend/src/services/hazel-session.ts @@ -0,0 +1,116 @@ +import { HazelAuth } from "@hazel/auth/server" +import { UserRepo } from "@hazel/backend-core" +import { CurrentUser, InvalidBearerTokenError, SessionLoadError } from "@hazel/domain" +import type { UserId } from "@hazel/schema" +import { Context, Effect, Layer, Option, Redacted } from "effect" +import { Cookies, Headers } from "effect/http" +import { HazelAuthInstance } from "./hazel-auth" + +/** + * Resolves Hazel sessions (`@hazel/auth/server`) to the current user for the RPC and + * HttpApi auth middleware. Browsers send the session as an HttpOnly cookie on the API + * host; native clients send the credential as a bearer token. + */ +export class HazelSession extends Context.Service()("@hazel/backend/HazelSession", { + make: Effect.gen(function* () { + const instance = yield* HazelAuthInstance + const auth = yield* Effect.serviceOption(HazelAuth) + const userRepo = yield* UserRepo + + const enabled = Option.isSome(instance) && Option.isSome(auth) + const cookieName = Option.isSome(instance) ? instance.value.sessionCookieName : undefined + const allowedOrigins = new Set( + Option.isSome(instance) + ? [instance.value.config.origin, ...(instance.value.config.trustedOrigins ?? [])] + : [], + ) + + const invalid = (detail: string) => + new InvalidBearerTokenError({ message: "Invalid session", detail }) + + const loadUser = (subjectId: string) => + userRepo.findById(subjectId as UserId).pipe( + Effect.catchTags({ + DatabaseError: (error) => + Effect.fail( + new SessionLoadError({ message: "Failed to load user", detail: String(error) }), + ), + }), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(invalid("User not found")), + onSome: (user) => + user.deletedAt !== null + ? Effect.fail(invalid("User is deleted")) + : Effect.succeed( + new CurrentUser.Schema({ + id: user.id, + role: "member", + organizationId: undefined, + avatarUrl: user.avatarUrl ?? undefined, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + isOnboarded: user.isOnboarded, + timezone: user.timezone, + settings: user.settings, + }), + ), + }), + ), + ) + + /** Verifies a session credential; `None` when it is not a valid Hazel session. */ + const verify = ( + credential: string, + ): Effect.Effect, InvalidBearerTokenError | SessionLoadError> => + Effect.gen(function* () { + if (Option.isNone(auth)) return Option.none() + const session = yield* auth.value.verifySession(Redacted.make(credential)).pipe( + Effect.map(Option.some), + Effect.catchTag("SessionInvalid", () => Effect.succeedNone), + // Every other outcome is the session store failing, not a bad credential. + Effect.mapError( + (error) => + new SessionLoadError({ + message: "Session verification failed", + detail: error._tag, + }), + ), + ) + if (Option.isNone(session)) return Option.none() + return Option.some(yield* loadUser(session.value.subjectId)) + }) + + /** + * The session cookie, if the request carries one. Cookie-authenticated requests must + * come from a trusted origin: mutations are POSTs, which browsers always send with an + * Origin, so a missing or foreign Origin means a cross-site request. + */ + const fromCookie = ( + headers: Headers.Headers, + method: string, + ): Effect.Effect, InvalidBearerTokenError | SessionLoadError> => + Effect.gen(function* () { + if (!enabled || cookieName === undefined) return Option.none() + const cookie = Option.flatMap(Headers.get(headers, "cookie"), (header) => + Option.fromNullishOr(Cookies.parseHeader(header)[cookieName]), + ) + if (Option.isNone(cookie)) return Option.none() + + const origin = Headers.get(headers, "origin") + const safe = method === "GET" || method === "HEAD" + if (!safe && (Option.isNone(origin) || !allowedOrigins.has(origin.value))) { + return yield* Effect.fail(invalid("Untrusted origin for a cookie session")) + } + + const user = yield* verify(cookie.value) + if (Option.isNone(user)) return yield* Effect.fail(invalid("Session expired or revoked")) + return user + }) + + return { enabled, verify, fromCookie } as const + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(UserRepo.layer)) +} diff --git a/apps/backend/src/services/invitations.test.ts b/apps/backend/src/services/invitations.test.ts new file mode 100644 index 000000000..652135594 --- /dev/null +++ b/apps/backend/src/services/invitations.test.ts @@ -0,0 +1,231 @@ +/** + * Email invitations end to end against a disposable Postgres: inviting (admins only, members + * skipped, one email per address), the public preview, accepting with the invited account, and + * revoking. Emails are captured instead of sent. + */ +import { InvitationRepo } from "@hazel/backend-core" +import { Database, PG, schema } from "@hazel/db" +import { CurrentUser } from "@hazel/domain" +import { CurrentRpcScopes } from "@hazel/domain/scopes" +import type { ApiScope } from "@hazel/domain/scopes" +import { OrganizationId, UserId } from "@hazel/schema" +import { ConfigProvider, Effect, Exit, Layer, Option, Schema } from "effect" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { createChatSyncDbHarness, type ChatSyncDbHarness } from "../test/chat-sync-db-harness" +import { type InviteEmail, InviteMailer } from "./invite-mailer" +import { Invitations } from "./invitations" + +const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}` +const orgId = Schema.decodeSync(OrganizationId)(id(1)) +const owner = Schema.decodeSync(UserId)(id(10)) +const member = Schema.decodeSync(UserId)(id(11)) +const invitee = Schema.decodeSync(UserId)(id(12)) + +const users = { + [owner]: "owner@hazel.test", + [member]: "member@hazel.test", + [invitee]: "Invitee@Hazel.test", +} as const + +const actor = (userId: UserId) => + new CurrentUser.Schema({ + id: userId, + role: "member", + organizationId: undefined, + avatarUrl: undefined, + firstName: "Test", + lastName: "User", + email: users[userId as keyof typeof users], + isOnboarded: true, + timezone: null, + settings: null, + }) + +let harness: ChatSyncDbHarness +const sent: Array = [] + +const CaptureMailer = Layer.succeed(InviteMailer, { + send: (invite) => Effect.sync(() => void sent.push(invite)), +}) + +/** Runs as `userId` with the RPC's annotated scope, like a request through the RPC middleware. */ +const run = ( + userId: UserId, + scope: ApiScope, + effect: (invitations: typeof Invitations.Service) => Effect.Effect, +) => + harness.run( + Effect.gen(function* () { + const invitations = yield* Invitations + return yield* Effect.exit(effect(invitations)) + }).pipe( + Effect.provideService(CurrentUser.Context, actor(userId)), + Effect.provideService(CurrentRpcScopes, [scope]), + Effect.provide(Invitations.layer), + Effect.provide(CaptureMailer), + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromUnknown({ FRONTEND_URL: "https://app.hazel.test" })), + ), + ), + ) + +const tokenOf = (invite: InviteEmail) => new URL(invite.acceptUrl).pathname.split("/").at(-1)! + +const failureTag = (exit: Exit.Exit) => + Exit.isFailure(exit) + ? exit.cause.reasons.map((reason) => + "error" in reason && + typeof reason.error === "object" && + reason.error !== null && + "_tag" in reason.error + ? String(reason.error._tag) + : reason._tag, + ) + : [] + +describe("invitations", () => { + beforeAll(async () => { + harness = await createChatSyncDbHarness(process.env.AUTH_TEST_DATABASE_URL) + await harness.reset() + await harness.run( + Effect.gen(function* () { + const db = yield* Database.Database + const setupSql = ` + INSERT INTO users (id, "externalId", email, "firstName", "lastName", "userType", "isOnboarded", "createdAt", "updatedAt") + VALUES + ('${owner}', 'owner', '${users[owner]}', 'Olive', 'Owner', 'user', true, NOW(), NOW()), + ('${member}', 'member', '${users[member]}', 'Max', 'Member', 'user', true, NOW(), NOW()), + ('${invitee}', 'invitee', '${users[invitee]}', 'Ivy', 'Invitee', 'user', true, NOW(), NOW()); + INSERT INTO organizations (id, name, slug, "isPublic", "createdAt", "updatedAt") + VALUES ('${orgId}', 'Hazel Labs', 'hazel-labs', false, NOW(), NOW()); + INSERT INTO organization_members (id, "organizationId", "userId", role, "joinedAt", "createdAt") + VALUES + ('${id(20)}', '${orgId}', '${owner}', 'owner', NOW(), NOW()), + ('${id(21)}', '${orgId}', '${member}', 'member', NOW(), NOW()); + ` + yield* Effect.forEach( + setupSql.split(";").filter((statement) => statement.trim() !== ""), + (statement) => db.execute(Database.sql.raw(statement)), + ) + }), + ) + }, 120_000) + + afterAll(async () => { + await harness?.stop() + }) + + it("only admins and owners invite", async () => { + const exit = await run(member, "organization-members:write", (invitations) => + invitations.create({ organizationId: orgId, emails: ["new@hazel.test"], role: "member" }), + ) + expect(failureTag(exit)).toEqual(["UnauthorizedError"]) + }) + + it("invites new addresses, skips members, and the invited account accepts", async () => { + sent.length = 0 + const created = await run(owner, "organization-members:write", (invitations) => + invitations.create({ + organizationId: orgId, + emails: ["invitee@hazel.test", "MEMBER@hazel.test"], + role: "admin", + }), + ) + expect(Exit.isSuccess(created) && created.value.invited.map((invite) => invite.email)).toEqual([ + "invitee@hazel.test", + ]) + expect(Exit.isSuccess(created) && created.value.skipped).toEqual([ + { email: "member@hazel.test", reason: "AlreadyMember" }, + ]) + expect(sent).toHaveLength(1) + expect(sent[0]).toMatchObject({ to: "invitee@hazel.test", organizationName: "Hazel Labs" }) + expect(sent[0]!.acceptUrl).toMatch(/^https:\/\/app\.hazel\.test\/invite\//) + const token = tokenOf(sent[0]!) + + const listed = await run(owner, "organization-members:read", (invitations) => + invitations.list({ organizationId: orgId }), + ) + expect(Exit.isSuccess(listed) && listed.value.map((invite) => invite.role)).toEqual(["admin"]) + + const preview = await run(member, "organizations:read", (invitations) => + invitations.preview({ token }), + ) + expect(Exit.isSuccess(preview) && preview.value.status).toBe("Pending") + + const wrongAccount = await run(member, "organizations:write", (invitations) => + invitations.accept({ token }), + ) + expect(failureTag(wrongAccount)).toEqual(["InvitationEmailMismatchError"]) + + const accepted = await run(invitee, "organizations:write", (invitations) => + invitations.accept({ token }), + ) + expect(Exit.isSuccess(accepted) && accepted.value.slug).toBe("hazel-labs") + const role = await harness.run( + Effect.gen(function* () { + const db = yield* Database.Database + return yield* db.run( + PG.from(schema.organizationMembersTable) + .select("role") + .where(($) => [$.organizationId.eq(orgId), $.userId.eq(invitee)]), + ) + }), + ) + expect(role).toEqual([{ role: "admin" }]) + + const again = await run(invitee, "organizations:write", (invitations) => + invitations.accept({ token }), + ) + expect(failureTag(again)).toEqual(["InvitationUnavailableError"]) + }, 60_000) + + it("re-inviting replaces the link, and a revoked link stops working", async () => { + sent.length = 0 + const invite = () => + run(owner, "organization-members:write", (invitations) => + invitations.create({ organizationId: orgId, emails: ["late@hazel.test"], role: "member" }), + ) + await invite() + const second = await invite() + const [first, latest] = [tokenOf(sent[0]!), tokenOf(sent[1]!)] + expect(first).not.toBe(latest) + + const stale = await run(member, "organizations:read", (invitations) => + invitations.preview({ token: first }), + ) + expect(failureTag(stale)).toEqual(["InvitationNotFoundError"]) + + const invitationId = Exit.isSuccess(second) ? second.value.invited[0]!.id : undefined + const revoked = await run(owner, "organization-members:write", (invitations) => + invitations.revoke({ id: invitationId! }), + ) + expect(Exit.isSuccess(revoked)).toBe(true) + const preview = await run(member, "organizations:read", (invitations) => + invitations.preview({ token: latest }), + ) + expect(Exit.isSuccess(preview) && preview.value.status).toBe("Revoked") + }, 60_000) + it("a stale send failure or acceptance cannot consume a replacement invitation", async () => { + const result = await harness.run( + Effect.gen(function* () { + const repo = yield* InvitationRepo + const data = { + organizationId: orgId, + email: "race@hazel.test", + role: "member" as const, + invitedBy: owner, + expiresAt: new Date(Date.now() + 60000), + } + const first = yield* repo.upsertPending({ ...data, tokenHash: "first-token" }) + const replacement = yield* repo.upsertPending({ ...data, tokenHash: "replacement-token" }) + const revoked = yield* repo.revoke(first.id, first.tokenHash) + const accepted = yield* repo.markAccepted(first.id, invitee, first.tokenHash) + const pending = yield* repo.findById(replacement.id) + return { revoked, accepted, pending } + }).pipe(Effect.provide(InvitationRepo.layer)), + ) + expect(Option.isNone(result.revoked)).toBe(true) + expect(Option.isNone(result.accepted)).toBe(true) + expect(Option.getOrNull(result.pending)?.status).toBe("pending") + }) +}) diff --git a/apps/backend/src/services/invitations.ts b/apps/backend/src/services/invitations.ts new file mode 100644 index 000000000..36af8baed --- /dev/null +++ b/apps/backend/src/services/invitations.ts @@ -0,0 +1,277 @@ +import { + ChannelMemberRepo, + ChannelRepo, + InvitationRepo, + OrganizationMemberRepo, + OrganizationRepo, +} from "@hazel/backend-core" +import { Database, type PG, type schema } from "@hazel/db" +import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/domain" +import { + Invitation, + InvitationAccepted, + InvitationCreateResult, + InvitationEmailMismatchError, + InvitationNotFoundError, + InvitationPreview, + type InvitationRole, + type InvitationSkip, + InvitationUnavailableError, + OrganizationNotFoundError, +} from "@hazel/domain/rpc" +import type { InvitationId, OrganizationId } from "@hazel/schema" +import { Config, Context, DateTime, Effect, Layer, Option } from "effect" +import { Base64Url, Hex } from "effect/encoding" +import { InvitationPolicy } from "../policies/invitation-policy" +import { ChannelAccessSyncService } from "./channel-access-sync" +import { InviteMailer } from "./invite-mailer" + +/** How long an invite link works. */ +const INVITATION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000 + +type InvitationRow = PG.SelectRowOf + +const newToken = () => Base64Url.encode(crypto.getRandomValues(new Uint8Array(32))) + +/** Only the hash is stored, so a database read can't produce working links. */ +const hashToken = (token: string) => + Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))).pipe( + Effect.map((digest) => Hex.encode(new Uint8Array(digest))), + ) + +const toInvitation = (row: InvitationRow) => + new Invitation({ + id: row.id, + organizationId: row.organizationId, + email: row.email, + role: row.role === "admin" ? "admin" : "member", + invitedBy: row.invitedBy, + createdAt: DateTime.fromDateUnsafe(row.createdAt), + expiresAt: DateTime.fromDateUnsafe(row.expiresAt), + }) + +const statusOf = (row: InvitationRow, now: Date) => + row.status === "accepted" + ? ("Accepted" as const) + : row.status === "revoked" + ? ("Revoked" as const) + : row.expiresAt <= now + ? ("Expired" as const) + : ("Pending" as const) + +/** + * Organization email invitations: invite by email, list and revoke (admins and owners), and the + * public link's preview and accept. The `invitation.*` RPCs delegate here. + */ +export class Invitations extends Context.Service()("Invitations", { + make: Effect.gen(function* () { + const db = yield* Database.Database + const policy = yield* InvitationPolicy + const invitationRepo = yield* InvitationRepo + const organizationRepo = yield* OrganizationRepo + const organizationMemberRepo = yield* OrganizationMemberRepo + const channelRepo = yield* ChannelRepo + const channelMemberRepo = yield* ChannelMemberRepo + const channelAccessSync = yield* ChannelAccessSyncService + const mailer = yield* InviteMailer + const frontendUrl = yield* Config.String("FRONTEND_URL") + + const findByToken = (token: string) => + hashToken(token).pipe( + Effect.flatMap(invitationRepo.findByTokenHash), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new InvitationNotFoundError()), + onSome: Effect.succeed, + }), + ), + ) + + const findOrganization = (organizationId: InvitationRow["organizationId"]) => + organizationRepo + .findById(organizationId) + .pipe( + Effect.flatMap((organization) => + Option.isSome(organization) && organization.value.deletedAt === null + ? Effect.succeed(organization.value) + : Effect.fail(new OrganizationNotFoundError({ organizationId })), + ), + ) + + return { + create: ({ + organizationId, + emails, + role, + }: { + readonly organizationId: OrganizationId + readonly emails: ReadonlyArray + readonly role: InvitationRole + }) => + Effect.gen(function* () { + yield* policy.canManage(organizationId, "create") + const currentUser = yield* CurrentUser.Context + const organization = yield* findOrganization(organizationId).pipe( + Effect.catchTag("OrganizationNotFoundError", () => + Effect.fail(new InternalServerError({ message: "Organization not found" })), + ), + ) + const inviterName = + `${currentUser.firstName} ${currentUser.lastName}`.trim() || currentUser.email + + const addresses = [...new Set(emails.map((email) => email.toLowerCase()))] + const members = yield* invitationRepo.findMemberEmails(organizationId, addresses) + + const invited: Array = [] + const skipped: Array = addresses + .filter((email) => members.has(email)) + .map((email) => ({ email, reason: "AlreadyMember" as const })) + + for (const email of addresses.filter((address) => !members.has(address))) { + const token = newToken() + const row = yield* invitationRepo.upsertPending({ + organizationId, + email, + role, + tokenHash: yield* hashToken(token), + invitedBy: currentUser.id, + expiresAt: new Date(Date.now() + INVITATION_LIFETIME_MS), + }) + const sent = yield* mailer + .send({ + to: email, + organizationName: organization.name, + inviterName, + acceptUrl: new URL(`/invite/${token}`, frontendUrl).href, + }) + .pipe(Effect.result) + if (sent._tag === "Success") { + invited.push(toInvitation(row)) + } else { + // A link nobody received shouldn't sit in the pending list. + yield* Effect.logWarning("[invite] email failed", { error: sent.failure.message }) + yield* invitationRepo.revoke(row.id, row.tokenHash) + skipped.push({ email, reason: "EmailFailed" }) + } + } + + return new InvitationCreateResult({ invited, skipped }) + }).pipe(withRemapDbErrors("Invitation", "create")), + + list: ({ organizationId }: { readonly organizationId: OrganizationId }) => + Effect.gen(function* () { + yield* policy.canManage(organizationId, "select") + const rows = yield* invitationRepo.listPending(organizationId, new Date()) + return rows.map(toInvitation) + }).pipe(withRemapDbErrors("Invitation", "select")), + + revoke: ({ id }: { readonly id: InvitationId }) => + Effect.gen(function* () { + const invitation = yield* invitationRepo.findById(id) + if (Option.isNone(invitation)) return yield* new InvitationNotFoundError() + yield* policy.canManage(invitation.value.organizationId, "delete") + yield* invitationRepo.revoke(id) + }).pipe(withRemapDbErrors("Invitation", "delete")), + + preview: ({ token }: { readonly token: string }) => + Effect.gen(function* () { + const invitation = yield* findByToken(token) + const organization = yield* findOrganization(invitation.organizationId).pipe( + Effect.catchTag("OrganizationNotFoundError", () => + Effect.fail(new InvitationNotFoundError()), + ), + ) + return new InvitationPreview({ + organizationName: organization.name, + organizationLogoUrl: organization.logoUrl ?? null, + email: invitation.email, + status: statusOf(invitation, new Date()), + }) + }).pipe(withRemapDbErrors("Invitation", "select")), + + accept: ({ token }: { readonly token: string }) => + db + .transaction( + Effect.gen(function* () { + const currentUser = yield* CurrentUser.Context + const invitation = yield* findByToken(token) + const status = statusOf(invitation, new Date()) + if (status !== "Pending") return yield* new InvitationUnavailableError({ status }) + if (currentUser.email.toLowerCase() !== invitation.email) + return yield* new InvitationEmailMismatchError({ + invitedEmail: invitation.email, + }) + + const organization = yield* findOrganization(invitation.organizationId) + const accepted = yield* invitationRepo.markAccepted( + invitation.id, + currentUser.id, + invitation.tokenHash, + ) + if (Option.isNone(accepted)) + return yield* new InvitationUnavailableError({ status: "Accepted" }) + + const existing = yield* organizationMemberRepo.findByOrgAndUser( + organization.id, + currentUser.id, + ) + if (Option.isNone(existing) || existing.value.deletedAt !== null) { + yield* organizationMemberRepo.upsertByOrgAndUser({ + organizationId: organization.id, + userId: currentUser.id, + role: invitation.role, + nickname: undefined, + joinedAt: new Date(), + invitedBy: invitation.invitedBy, + deletedAt: null, + }) + // Like joining by public link: into #general. + const general = yield* channelRepo.findByOrgAndName( + organization.id, + "general", + ) + if (Option.isSome(general)) { + const membership = yield* channelMemberRepo.findByChannelAndUser( + general.value.id, + currentUser.id, + ) + if (Option.isNone(membership)) + yield* channelMemberRepo.insert({ + channelId: general.value.id, + userId: currentUser.id, + isHidden: false, + isMuted: false, + isFavorite: false, + lastSeenMessageId: null, + notificationCount: 0, + joinedAt: new Date(), + deletedAt: null, + }) + } + yield* channelAccessSync.syncUserInOrganization( + currentUser.id, + organization.id, + ) + } + + return new InvitationAccepted({ + organizationId: organization.id, + slug: organization.slug ?? null, + }) + }), + ) + .pipe(withRemapDbErrors("Invitation", "update")), + } + }), +}) { + /** Needs `Database`, `InviteMailer` and `FRONTEND_URL` from the platform. */ + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(InvitationPolicy.layer), + Layer.provide(ChannelAccessSyncService.layer), + Layer.provide(InvitationRepo.layer), + Layer.provide(OrganizationRepo.layer), + Layer.provide(OrganizationMemberRepo.layer), + Layer.provide(ChannelRepo.layer), + Layer.provide(ChannelMemberRepo.layer), + ) +} diff --git a/apps/backend/src/services/invite-mailer.test.ts b/apps/backend/src/services/invite-mailer.test.ts new file mode 100644 index 000000000..339ae95dd --- /dev/null +++ b/apps/backend/src/services/invite-mailer.test.ts @@ -0,0 +1,39 @@ +import { ConfigProvider, Effect, Layer, Result } from "effect" +import { describe, expect, it } from "vitest" +import { InviteMailer, renderInviteEmail } from "./invite-mailer" + +describe("renderInviteEmail", () => { + it("escapes names in the HTML and keeps the plain text readable", () => { + const email = renderInviteEmail({ + to: "ada@hazel.test", + organizationName: ` & Co`, + inviterName: "Grace", + acceptUrl: "https://app.hazel.sh/invite/abc", + }) + expect(email.subject).toBe(`Grace invited you to & Co on Hazel`) + expect(email.html).not.toContain("