Skip to main content

auth

Epicenter auth packages: `@epicenter/auth` and the Svelte adapter at `@epicenter/auth/svelte`, OAuth sessions, identity state, auth-owned fetch/WebSocket, and the reload gate that makes a page lifetime one auth generation. Use when editing Epicenter auth clients, session state, hosted sign-in, or how a route boots from auth.

معلومات المصدر

المستودع
EpicenterHQ/epicenter
آخر نشاط في المصدر
٤ سبتمبر ٢٠٢٦ في ٠٩:٢٠
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٤٬٨١٧
التفرعات
٣٨٥

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
name
auth
description
Epicenter auth packages: `@epicenter/auth` and the Svelte adapter at `@epicenter/auth/svelte`, OAuth sessions, identity state, auth-owned fetch/WebSocket, and the reload gate that makes a page lifetime one auth generation. Use when editing Epicenter auth clients, session state, hosted sign-in, or how a route boots from auth.
metadata
{"author":"epicenter","version":"8.0"}
# Epicenter Auth ## Upstream Grounding When changes depend on Better Auth OAuth provider behavior, bearer token verification, cookie handling, token rotation, plugin shape, JWKS, or generated API shape, ask DeepWiki a narrow question against `better-auth/better-auth` before relying on memory. Use it to orient, then verify decisive details against local installed types, source, tests, or official docs before changing code. Known Better Auth source landmarks: ```txt packages/oauth-provider/src/oauth.ts packages/oauth-provider/src/authorize.ts packages/oauth-provider/src/token.ts packages/oauth-provider/src/revoke.ts packages/oauth-provider/src/client-resource.ts packages/better-auth/src/plugins/jwt/index.ts (ES256 signing + JWKS) ``` Better Auth remains the auth server and session engine. Epicenter extends it through plugins and options; it does not replace Better Auth's server-side session model. Use this composition sentence when explaining the architecture: ```txt Epicenter uses Better Auth for auth-server machinery, OAuth for the app/resource boundary, and AuthState{principalId} for workspace boot. ``` That means Better Auth owns users, account cookies, login, consent, token issuing, revocation, JWKS, and metadata. Epicenter clients store `PersistedAuth`, not Better Auth sessions. `/api/session` is the adapter that verifies a credential, resolves the request to a `principalId`, and returns `ApiSessionResponse`. When the user asks whether this is idiomatic Better Auth, be precise: ```txt It is not the shortest Better Auth browser-cookie path. It is an idiomatic composition of Better Auth as the auth server beneath a cross-client OAuth runtime. ``` Do not suggest removing Better Auth unless the user has a concrete blocker that cannot be handled with configuration, a small adapter, or an upstream fix. Building OAuth by hand means owning PKCE validation, redirect URI validation, state and mix-up protections, trusted clients, token signing, refresh token rotation, revocation, JWKS, metadata, consent, account sessions, and security fixes forever. ## Vocabulary: principal, not owner Client and server speak one identity word: `principalId` (branded `PrincipalId` from `@epicenter/principal`). There is no `ownerId` / `OwnerId` in the codebase. On a self-hosted instance every valid bearer resolves to the literal `INSTANCE_PRINCIPAL_ID` (`'instance'`). If you see `owner` anywhere, it is stale prose, not a symbol. ## Current Model: three credential clients, composed by the app An app composes the credential model it needs. There is no dispatcher: a build was made against one deployment and names it (ADR-0326), so nothing reads an instance setting to choose between these. - `createOAuthAppAuth(...)` — the hosted default. PKCE bearer + transparent refresh + a `/api/session` network gate + `openWebSocket`. Every cross-origin / native app uses this (web, extension, Tauri). - `createInstanceTokenAuth(...)` — a self-hosted star (a static token). No OAuth flow, launcher, refresh, or persisted grant; boots optimistically `signed-in` as `INSTANCE_PRINCIPAL_ID` and verifies `/api/session` in the background (surfacing the result on `connection.status`, which is the only client whose status is a live machine). Carries the bearer subprotocol, so it can open the sync socket. - `createSameOriginCookieAuth(...)` — the same-origin dashboard SPA (`apps/api/ui`). Uses the first-party Better Auth cookie directly. It has `openWebSocket` like every client and denies permanently, because a cookie cannot carry the subprotocol the rooms route requires. These are three credential models, not mode flags on one client. The old `createCookieAuth` / `createBearerAuth` split (and `BearerSession` / `auth.bearerToken`) is fully removed; do not reintroduce those names. `createOAuthAppAuth` and `createInstanceTokenAuth` both attach a bearer, so they share one internal transport: `fetchWithBearer` in `packages/auth/src/bearer-fetch.ts`, parameterized on how each resolves its token (the OAuth client's network gate vs the instance client's static token). Do not re-duplicate the attach-bearer-only-to-the-signed-in-origin logic; route new bearer clients through that helper. The hosted OAuth factory in one shape: ```ts const auth = createOAuthAppAuth({ baseURL: EPICENTER_API_URL, clientId, launcher, persistedAuthStorage, }); ``` Apps rarely call `createOAuthAppAuth` directly. `createHostedBrowserRedirectAuth` in `@epicenter/auth` packages the convention every hosted web app repeats: the persisted-grant key, the issuer, the redirect, the resource, and where PKCE state lives. It takes only what varies per app (the application id, the OAuth client id, and the hosted API origin) and returns a plain `AuthClient`. The application id is the one `createEpicenter` takes, because it is the same application: it scopes the persisted grant to `<appId>.auth.persisted`. A Tauri build keeps its own deep-link launcher and uses this for its web build alone (ADR-0078). The public surface lives in one package plus a Svelte subpath: - `@epicenter/auth`: framework-agnostic core. Owns the persisted auth cell, refresh, refresh-token revocation, `/api/session` verification, the network gate, authenticated fetch, and WebSocket opening. There is no headless or terminal surface: every credential model here is driven by an app. - `@epicenter/auth/svelte`: one adapter, `fromAuth(authClient)`, plus a re-export of `reloadOnAuthChange`. It mirrors `auth.state` and `connection.status` through `createSubscriber` so templates and `$derived` reads are reactive, and returns `ReactiveAuthClient`, which is `AuthClient` plus a wellcrafted `Brand`: a component whose reads must track asks for the branded type, and a boot-time reader keeps accepting plain `AuthClient`, since the brand is a subtype. Handing a raw core client to a component that tracks is a type error rather than a silently frozen surface. It holds no conventions. A composition that has nothing to do with a framework belongs in `@epicenter/auth`, and a platform leaf is the line that puts the two together, and it names both halves so a boot reader and a component ask for different things: ```ts export const authClient = createHostedBrowserRedirectAuth({ appId: APP_ID, oauthClientId, baseURL }); export const auth = fromAuth(authClient); ``` The API server composes Better Auth like this: ```txt Hono app -> origin/trusted-origin resolution -> CORS -> /api/* CSRF guard for cookie mutations -> per-request DB (mountCloudDb) -> createAuth (mountCloudAuth): /auth/* Better Auth handler + /sign-in + /consent -> /api/session (mountSessionApp: requireCookieOrBearerPrincipal) -> protected resources (requireBearerPrincipal; rooms via requireRoomBearer) ``` `createAuth()` configures Better Auth with Drizzle (Postgres via Hyperdrive), Google sign-in always, GitHub / Microsoft / Apple registered when their credentials are present (Apple mints an ES256 client-secret JWT), and exactly two plugins: ```ts jwt({ jwks: { keyPairConfig: { alg: JWT_SIGNING_ALG } } }), // ES256 oauthProvider({ loginPage: '/sign-in', consentPage: '/consent', requirePKCE: true, accessTokenExpiresIn: 600, validAudiences: [apiBaseURL], allowDynamicClientRegistration: false, scopes: [...EPICENTER_OAUTH_SCOPES], }) ``` There are no bearer, device-authorization, or custom-session plugins. Local email/password is disabled (`emailAndPassword: { enabled: false }`): enabling unverified local credentials reopens an account-linking takeover on better-auth 1.5.6 (no `requireLocalEmailVerified` gate). Only Google is a trusted linking provider; see the `better-auth-security` skill's Account Linking note. ## Public Surface `AuthState` lives in `@epicenter/auth`, beside the clients that produce it. `PrincipalId` lives in `@epicenter/principal`, a leaf shared by the store and the auth client because neither depends on the other. Both were in a package called `@epicenter/identity` until 2026-08, held together by a license firewall that no longer exists. ```ts export type AuthState = | { status: 'signed-out' } | { status: 'signed-in'; principalId: PrincipalId } | { status: 'reauth-required'; principalId: PrincipalId }; export type ConnectionStatus = | 'connecting' | 'connected' | 'unreachable' | 'rejected'; /** The ONE server this client represents. Switching it starts a new auth generation. */ export type Connection = { baseURL: string; get status(): ConnectionStatus; onChange(fn: (status: ConnectionStatus) => void): () => void; }; ``` Only the self-host token client drives `status` through a real machine (`instance-credential-authority.ts`). The hosted OAuth client and the same-origin cookie client report a constant `connected` and an `onChange` that never fires, because the hosted star's reachability is not a fact those models track. Do not read a live connection status as though every client had one. The client contract (`packages/auth/src/auth-contract.ts`), trimmed of JSDoc: ```ts export type AuthClient = { state: AuthState; connection: Connection; onStateChange(fn: (state: AuthState) => void): () => void; startSignIn(): Promise<Result<undefined, AuthError>>; signOut(): Promise<Result<undefined, AuthError>>; fetch(input: Request | string | URL, init?: RequestInit): Promise<Response>; getProfile(): Promise<Result<Principal, AuthError>>; openWebSocket(url: string | URL, protocols?: string[]): Promise<WebSocket>; [Symbol.dispose](): void; }; ``` There is no `SyncAuthClient` subtype. `openWebSocket` is on every client, and a client that can never open one rejects with a permanent `OpenWebSocketDenial` instead. The reasoning is in `auth-contract.ts`: a caller has to handle the denial either way, so the models that can never sync are the permanent arm of a channel every caller already needs, not a type to demand. `AuthState` arms carry `principalId` directly. There is no nested identity object and no `user` field in state: profile (the email) is fetched on demand via `getProfile()` by the surface that displays it, never held in state. `principalId` is present in `signed-in` and `reauth-required` because it is the local partition key: even when the OAuth grant needs reauth, the cached principal id picks the right local storage partition. `connection` is the one server this client represents, fixed for the client's whole life: switching it starts a new auth generation. There is no `kind` discriminator on it, because the credential model is recomputed from the `Instance` at construction rather than stored as a tag. A surface that needs to know it is self-hosted asks the `InstanceSetting` (`!instanceConnect.setting.isDefault()`, as `account-popover.svelte` does), not the client. Only a self-hosted instance carries a live `connection.status` (the boot bearer check against the box); the other two report a constant `connected` and an `onChange` that never fires. Whether a client can sync is answered at runtime by `openWebSocket`'s denial, not by a type. A caller must handle the denial anyway, so a sync-capable subtype would buy a compile error on top of a branch that still has to exist. Read `auth.state` synchronously. Use `auth.onStateChange(fn)` for future changes only; it does not replay. Consumers that need bootstrap behavior must read `auth.state` once and then register the listener. Do not expose raw tokens above auth storage and transport boundaries. UI, workspace binding, AI fetches, and sync consume capabilities: `auth.fetch` and `auth.openWebSocket`. ## The Persisted Cell `PersistedAuth` is the single durable auth record for the OAuth client (`packages/auth/src/auth-types.ts`): ```ts export const Principal = type({ '+': 'delete', id: PrincipalId, 'email?': 'string', }); export const OAuthTokenGrant = type({ '+': 'delete', accessToken: 'string', refreshToken: 'string', accessTokenExpiresAt: 'number', }); export const PersistedAuth = type({ '+': 'delete', grant: OAuthTokenGrant, principalId: PrincipalId, }); export const ApiSessionResponse = type({ '+': 'delete', principalId: PrincipalId, 'email?': 'string', }); ``` The grant is a nested object; identity is a single `principalId`: ```txt PersistedAuth grant: { accessToken, refreshToken, accessTokenExpiresAt } -> online-only server access principalId -> local storage partition selection (offline-useful) ``` The grant lets the app call the server and is useless offline on its own. `principalId` stays useful offline: it selects this principal's local workspace data. Profile data is intentionally absent; application surfaces fetch it via `getProfile()` when they display it. The app can boot from a cached `PersistedAuth` without calling the network. Refresh failure must preserve the cached `principalId` so local workspace data stays available. The cached principal id selects the local storage partition; it does not decrypt anything. ## Network Gate (local-first invariant) The runtime tracks a `networkAccess` state per signed-in cell (internal to `createOAuthAppAuth`): ```txt networkAccess: 'unverified' | 'verified' | 'paused' ``` `bearerForNetwork` is the gate. It NEVER attaches a bearer until `/api/session` verifies the current persisted auth in this runtime: ```txt signed-out / paused -> no bearer refresh stale grant -> if refresh fails, no bearer (offline = fail closed) unverified -> call /api/session ok -> mark verified, attach bearer Rejected (401/403) -> pauseNetworkAuth() -> reauth-required Unavailable (offline) -> no bearer; local workspace boot can continue by principalId ``` Fail closed offline: server access is refused until the current persisted auth has been verified by the API, but local workspace boot continues because the cached `principalId` selects the right local partition. A different-`principalId` `/api/session` response wipes the local cell (same-principal guard). `auth.fetch` layers retry on top of the gate: verify-before-attach, `credentials: 'omit'`, one forced-refresh retry on a 401, and `pauseNetworkAuth()` on a second 401. ## Sign-In Flow Apps ask auth to start hosted sign-in. `startSignIn` takes NO arguments: ```ts await auth.startSignIn(); ``` The launcher decides how the runtime completes OAuth and returns one of two shapes: - `'launched'`: control moved to a redirect / deep-link callback. The browser redirect launcher navigates to the hosted `/sign-in` and usually does not resolve before the page unloads. - `'completed'` with `{ grant }`: the launcher exchanged a token grant in process (the extension). The runtime then calls `/api/session`, resolves identity, and persists `PersistedAuth`. The return value of `startSignIn` is not the "user is signed in" signal. Observe `auth.state.status === 'signed-in'` for completion. (On the instance-token client, `startSignIn` re-runs the `/api/session` verification so a UI can retry a connection that was offline at boot.) ## PersistedAuthStorage Port Storage is a small port (`packages/auth/src/persisted-auth-storage.ts`): ```ts export type PersistedAuthStorage = { initial: PersistedAuth | null; set(value: PersistedAuth | null): void | Promise<void>; }; ``` `initial` is read exactly once, synchronously, at construction to seed the state machine; it is never re-read. `set` is the only write path (no watch hook: cross-context sign-out propagates via the server, where the next bearer-bearing call hits a revoked token and reauth-requires organically). Adapters: - `createWebStoragePersistedAuthStorage({ key, storage })`: sync Web Storage (`localStorage` / `sessionStorage`). A corrupt record reads as signed-out instead of throwing; write failures propagate so an unpersistable credential fails its sign-in or refresh. - `loadPersistedAuthStorage({ read, write })`: pre-load an async-backed store (extension `chrome.storage.local`, a file, the Tauri OS keyring) into a synchronous port. Await it before constructing the client so `initial` stays synchronous. - `parsePersistedAuth` / `serializePersistedAuth`: the shared decode/encode helpers (re-validate against the arktype on both sides). ## Transport Use `auth.fetch` for HTTP resources: ```ts const response = await auth.fetch(`${EPICENTER_API_URL}/api/ai/chat`, { method: 'POST', body, }); ``` `auth.fetch` runs the network gate (verify-before-attach), sends `credentials: 'omit'` so OAuth tokens stay the resource credential, retries one 401 after a forced refresh, and pauses network auth on a second 401. Storage writes are awaited before a refreshed token is used. Use `auth.openWebSocket` for sync: ```ts const collaboration = openCollaboration(workspace.ydoc, { url: roomWsUrl({ baseURL, principalId, guid: workspace.ydoc.guid, nodeId }), waitFor: idb.whenLoaded, openWebSocket: signedIn.openWebSocket, onReconnectSignal: signedIn.onReconnectSignal, }); ``` Browsers cannot attach `Authorization` headers to `new WebSocket()`, so auth carries the bearer token as a WebSocket subprotocol (`BEARER_SUBPROTOCOL_PREFIX`). The rooms route extracts that credential itself on upgrade (an explicit `Authorization` header wins; else exactly one `bearer.<token>` entry) and feeds the bare token to the deployment's `ResolveBearerPrincipal`. Nothing rewrites `c.req.raw`: Bun's `server.upgrade` only accepts the runtime-minted request. The backends echo only the `epicenter` subprotocol on every 101 (accept and reject), so the token never round-trips. ## Stateless access tokens and revocation windows The OAuth provider issues JWT access tokens that the resource server verifies statelessly against JWKS (no per-request introspection). That is fast, but it means a token cannot be revoked before it expires: signing out revokes the refresh token, not the already-issued access token. Three mitigations follow from that one invariant and only make sense together. Treat them as a unit.
عرض على GitHub
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub