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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@hazel/infra": "workspace:*",
"@hazel/integrations": "workspace:*",
"@hazel/schema": "workspace:*",
"@yielded/auth": "0.1.0-beta.32",
"alchemy": "catalog:alchemy",
"aws4fetch": "^1.0.20",
"dfx": "^1.1.0",
Expand Down
31 changes: 28 additions & 3 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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"
Expand Down Expand Up @@ -103,7 +105,13 @@ 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: [
Expand All @@ -119,6 +127,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,
Expand Down Expand Up @@ -204,5 +222,12 @@ export const AppServicesLive = Layer.mergeAll(
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),
)
10 changes: 9 additions & 1 deletion apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
* `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 { 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"
Expand All @@ -24,12 +26,18 @@ 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,
Expand Down
61 changes: 61 additions & 0 deletions apps/backend/src/rpc/middleware/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CurrentUser, type CurrentUser as CurrentUserNamespace } from "@hazel/do
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"

Expand Down Expand Up @@ -73,6 +74,19 @@ const makeSessionManagerLayer = (currentUser: CurrentUserNamespace.Schema) =>
}),
)

type HazelSessionShape = Context.Service.Shape<typeof HazelSession>

const makeHazelSessionLayer = (overrides: Partial<HazelSessionShape> = {}) =>
Layer.succeed(
HazelSession,
serviceShape<typeof HazelSession>({
enabled: false,
verify: () => Effect.succeedNone,
fromCookie: () => Effect.succeedNone,
...overrides,
}),
)

const makeBotRepoLayer = (findByTokenHash: BotRepoShape["findByTokenHash"]) =>
Layer.succeed(
BotRepo,
Expand Down Expand Up @@ -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,
Expand All @@ -128,6 +144,7 @@ const runAuth = (
headers: Headers.Headers,
overrides: {
sessionManager?: ReturnType<typeof makeSessionManagerLayer>
hazelSession?: ReturnType<typeof makeHazelSessionLayer>
botRepo?: ReturnType<typeof makeBotRepoLayer>
userRepo?: ReturnType<typeof makeUserRepoLayer>
} = {},
Expand All @@ -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<BotRecord>())),
),
Expand Down Expand Up @@ -215,4 +233,47 @@ 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("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)
})
})
21 changes: 20 additions & 1 deletion apps/backend/src/rpc/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -31,12 +32,19 @@ 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 cookieUser = yield* hazelSession.fromCookie(headers, "POST")
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
const authHeader = Headers.get(headers, "authorization")

if (Option.isSome(authHeader) && authHeader.value.startsWith("Bearer ")) {
Expand All @@ -48,6 +56,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))

Expand Down
Loading
Loading