Skip to content
Merged
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
10 changes: 8 additions & 2 deletions brain/knowledge/connections-auth/ce-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,21 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses
- `accessTokenManager`: `generateToken` (7-day JWT), `generateEngineToken`/`generateWorkerToken` (long-lived), `verifyPrincipal` (checks tokenVersion + active status).

### How it works
- Token is a short-lived JWT (7 days) signed with a shared secret. `PrincipalType`: USER, ENGINE, WORKER, SERVICE, UNKNOWN.
- Token is a short-lived JWT (7 days) signed with a shared secret. `PrincipalType`: USER, ENGINE, WORKER, SERVICE, UNKNOWN, ONBOARDING.
- Endpoints (all rate-limited via `API_RATE_LIMIT_AUTHN_*`): `POST /v1/authentication/sign-up`, `/sign-in`, `/switch-platform`.
- First sign-up side effects: creates identity → User (PlatformRole.ADMIN) → Platform (`"<firstName>'s Platform"`) → default PERSONAL project; sends OTP on Cloud prod, auto-verifies otherwise; fires `USER_CREATED` flag + `SIGNED_UP` telemetry.
- First sign-up side effects: creates identity → User (PlatformRole.ADMIN) → default PERSONAL project; sends OTP on Cloud prod, auto-verifies otherwise; fires `USER_CREATED` flag + `SIGNED_UP` telemetry.
- **`signUp` has two arms and only one of them can create a platform.** When `params.platformId` is set (self-hosted, or a custom domain) the member joins that existing platform through `getOrCreateWithProject` and no platform is ever created or named. When it is nil (Cloud only) the identity is created first, then `getPreferredPlatformId` looks for a platform the identity already belongs to; finding none it returns an ONBOARDING response, and the member names the platform themselves at `/create-platform`. `getPreferredPlatformId` returns null on every non-Cloud edition. There is no `"<firstName>'s Platform"` autoname in production; that string lives only in `dev-seeds.ts`.
- **ONBOARDING** is the pre-platform principal: `authenticationUtils.getOnboardingResponse` mints it with `platformId: null, projectId: null` for a verified identity that belongs to no platform yet, so the member can call `POST /v1/platforms` (`securityAccess.unscoped([ONBOARDING, USER])`) and land on `/create-platform`. It is Cloud-only in practice, because on self-hosted `platformUtils.getPlatformIdForRequest` falls back to `getOldestPlatform()` and there is always a platform to join. `accessTokenManager.assertUserSession` still revalidates it against `tokenVersion` + `verified`.
- **Passwordless sign-in** (`EMAIL_LOGIN`) is a typed 6-digit code on the same OTP primitive, offered only when `ApFlagId.SMTP_CONFIGURED` is true, with password as the fallback path. See [000027](../decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md) for the code-not-link, edition-reach and anti-enumeration reasoning.

### Gotchas
- Email-auth checks and domain allow-listing guards are **skipped on Community** edition.
- OTP verification only sent on Cloud production; CE/EE and Cloud-dev (`AP_ENVIRONMENT=development`) auto-verify the identity.
- Telemetry PII (email/name) sent only on Cloud; CE/EE send non-PII fields (`pickTelemetryPii`). Sign-in telemetry covers password sign-in only, not SSO.
- Sessions are invalidated by rotating `tokenVersion` on `UserIdentity`.
- **A new unauthenticated endpoint must be added to `disallowedRoutes` in `packages/web/src/lib/api.ts`**, otherwise the SPA attaches whatever stale bearer token is still in storage and the call fails in exactly the situation the endpoint exists for.
- **The three signup guards in `authentication-utils.ts` differ in what they leak.** `assertEmailAuthIsEnabled` and `assertDomainIsAllowed` describe platform configuration, so surfacing their errors is safe. `assertUserIsInvitedToPlatformOrProject` describes one address, so surfacing it turns any public auth endpoint into an invitation oracle. All three are also inert unless `plan.ssoEnabled`.
- **A nil `projectId` on the principal means "go to /create-platform" in four separate places.** Anything that mints a platform-less session has to satisfy all of them, not just the route guard.

### Key files
Entry point: `authenticationService`, a log-taking factory called per request from `authentication.controller.ts`, registered as `authenticationModule` in `app.ts`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ Enterprise auth layer extending CE with SAML 2.0 SSO, Google/GitHub federated OA
### How it works
- **SAML SSO**: `POST /v1/authn/saml/login` returns IdP redirect; IdP POSTs assertion to ACS `POST /v1/authn/saml/acs`; service parses email/name → federatedAuthn → JWT. Gated by `platform.plan.ssoEnabled`.
- **Federated OAuth (Google/GitHub)**: `/v1/authn/federated/login` returns redirect URL; `/v1/authn/federated/claim` exchanges code → JWT. Redirects always use `FRONTEND_URL` (no custom domain).
- **OTP** (`EMAIL_VERIFICATION`, `PASSWORD_RESET`): per-type expiry (`OTP_EXPIRATION_MS` in `otp-service.ts`: 24h verification, 10-min reset); states PENDING/CONFIRMED. Resend re-delivers the existing pending OTP value WITHOUT touching the row — expiry stays anchored to the value's creation, so resends cannot extend a (possibly compromised) OTP's lifetime; a new value is generated only once the OTP is expired or confirmed (GIT-1733: the old early-return made resend a silent 204 no-op). Known bounded edge: a resend requested just before expiry delivers a short-lived link; the next resend regenerates.
- **OTP** (`EMAIL_VERIFICATION`, `PASSWORD_RESET`, `EMAIL_LOGIN`): per-type expiry (`OTP_EXPIRATION_MS` in `otp-service.ts`: 24h verification, 10-min reset, 10-min login); states PENDING/CONFIRMED; one row per `(identityId, type)`, DB-enforced. Resend re-delivers the existing pending value WITHOUT touching the row — expiry stays anchored to the value's creation, so resends cannot extend a (possibly compromised) OTP's lifetime; a new value is generated only once the old one is expired or spent (GIT-1733: the old early-return made resend a silent 204 no-op). The first two types carry a `randomUUID()` delivered as a link; `EMAIL_LOGIN` carries a 6-digit code the member types, and its row counts `attempts` so it dies after five wrong guesses — counted in raw SQL for the same reason resend leaves the row alone, since touching `updated` would buy the guesser another window. Known bounded edge: a resend requested just before expiry delivers a short-lived link; the next resend regenerates. See [000027](../decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md).
- **Enterprise local auth**: `verifyEmail` (confirms OTP → sets verified), `resetPassword` (confirms OTP → updates hash), both audit-logged.
- **RBAC**: `assertPrincipalAccessToProject({principal, permission, projectId})` and `assertUserHasPermissionToFlow` (maps FlowOperationType → Permission). Authorization hooks: `platformMustHaveFeatureEnabled` (402 FEATURE_DISABLED), `projectMustBeTeamType`, `platformMustBeOwnedByCurrentUser`.

### Gotchas
- CE gets OTP flows + RBAC base types; **SSO, managed auth, federated OAuth are EE/Cloud only**.
- **Until the passwordless work, CE could not send an OTP at all, despite the entity being registered for every edition.** `otpModule` was registered only in the CLOUD and ENTERPRISE arms of `app.ts`, and `emailService.sendOtp` returned early when the edition was neither. So on CE the table existed, the migration ran, and nothing could ever be sent. `EMAIL_LOGIN` changed that: `otpModule` is now registered for COMMUNITY too, and `EMAIL_LOGIN` is the one type carved out of the paid-edition send gate, so it reaches every edition while the UI gates it on `SMTP_CONFIGURED`. The two link types are still paid-edition only. RBAC base types are CE; **SSO, managed auth, federated OAuth are EE/Cloud only**.
- **The public `POST /v1/otp` route deliberately cannot mint a login code.** Its `CreateOtpRequestBody` narrows `type` to `EMAIL_VERIFICATION | PASSWORD_RESET`, because that route is unauthenticated, carries no `rateLimit` config, and applies none of the sign-up guards. `EMAIL_LOGIN` is issued only through `POST /v1/authentication/otp/request`, which is rate limited and gated. Widening that enum back to the whole `OtpType` hands anyone an unthrottled "email a working sign-in code to this address" primitive.
- **A code sign-in must re-assert the platform's auth policy at verify time, not only at request time.** On Cloud `platformUtils.getPlatformIdForRequest` returns null for every unauthenticated request, so the request-scoped branch never runs there and the platform is only known after the identity is resolved. `verifyCode` therefore calls the same `assertEmailAuthIsEnabled` + `assertDomainIsAllowed` pair on the resolved preferred platform; without that, an email code signs a member into a platform that has deliberately disabled email auth or removed their domain. It is not asserted at request time on purpose, because reporting those errors for a resolved address would turn the request endpoint into an existence oracle.
- **`otpService.confirm` used to refresh its own resend lock.** `updated` is an `updateDate` column, so marking a row CONFIRMED touched it and the ten-minute guard then refused to issue that identity another code for ten minutes after a successful verify. Rows are deleted on confirm now.
- **One constant is both the expiry and the resend suppression.** `TEN_MINUTES` gates `confirm`'s freshness check and `createAndSend`'s "an OTP already exists" early return, so before this work a resend was impossible until the current credential expired, and the request endpoint still answered 204. Resend now re-delivers the existing value without touching `updated`.
- **`email-service.ts` is not exhaustive over `OtpType`.** `frontendPath` is a literal keyed by only two members but indexed by the whole union, so adding a member is a compile break; its sibling `otpToTemplate` is typed `Record<string, EmailTemplateData>`, which type-checks and hands `undefined` to the sender at runtime instead.
- SSO settings page wrapped in `LockedFeatureGuard` keyed on `ssoEnabled`.
- Managed auth gated separately by `embeddingEnabled` (signing keys). See the Managed Auth page.
- The authn rate limiter (`core/security/rate-limit.ts`) is registered with `global: false` — it protects NOTHING by default. Every public endpoint that sends email or does auth work must opt in per-route via `config.rateLimit` (see `authentication.controller.ts` / `otp-controller.ts` for the `API_RATE_LIMIT_AUTHN_*` pattern).
Expand Down
4 changes: 2 additions & 2 deletions brain/knowledge/connections-auth/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ Platform owners register their own OAuth client_id/secret per piece so connectio

### CE Authentication

User identity, sign-in, JWT sessions. `UserIdentity` = canonical email+password+provider (one per email, shared across platforms); `User` = platform-scoped membership. First sign-up auto-creates a Platform + personal Project + ADMIN user. JWT is 7-day, signed with a shared secret; rotating `tokenVersion` on UserIdentity invalidates all sessions. `accessTokenManager` also mints long-lived engine/worker tokens. Endpoints: `/v1/authentication/sign-up|sign-in|switch-platform`. PrincipalTypes: USER/ENGINE/WORKER/SERVICE/UNKNOWN.
User identity, sign-in, JWT sessions. `UserIdentity` = canonical email+password+provider (one per email, shared across platforms); `User` = platform-scoped membership. First sign-up auto-creates a Platform + personal Project + ADMIN user. JWT is 7-day, signed with a shared secret; rotating `tokenVersion` on UserIdentity invalidates all sessions. `accessTokenManager` also mints long-lived engine/worker tokens. Endpoints: `/v1/authentication/sign-up|sign-in|switch-platform`. PrincipalTypes: USER/ENGINE/WORKER/SERVICE/UNKNOWN/ONBOARDING (the last is the pre-platform session that can only call `POST /v1/platforms`).

### EE Authentication

Extends CE with SSO + RBAC. SAML 2.0 (`/v1/authn/saml/login` → IdP → ACS `/acs`) and Google/GitHub federated OAuth both funnel into `authenticationService.federatedAuthn()`; gated by `ssoEnabled`. Per-project RBAC via `assertPrincipalAccessToProject()` and `assertUserHasPermissionToFlow()`. Config stored on `platform.federatedAuthProviders`. Authz hooks: `platformMustHaveFeatureEnabled` (402), `projectMustBeTeamType`, `platformMustBeOwnedByCurrentUser`. OTP (email verify + password reset) lives here but is available in CE too.
Extends CE with SSO + RBAC. SAML 2.0 (`/v1/authn/saml/login` → IdP → ACS `/acs`) and Google/GitHub federated OAuth both funnel into `authenticationService.federatedAuthn()`; gated by `ssoEnabled`. Per-project RBAC via `assertPrincipalAccessToProject()` and `assertUserHasPermissionToFlow()`. Config stored on `platform.federatedAuthProviders`. Authz hooks: `platformMustHaveFeatureEnabled` (402), `projectMustBeTeamType`, `platformMustBeOwnedByCurrentUser`. OTP (email verify, password reset, and the `EMAIL_LOGIN` sign-in code) lives here. Its entity is registered for every edition, but `otpModule` is only registered on Cloud/EE and `sendOtp` returns early off those editions, so CE can send nothing today except `EMAIL_LOGIN`, which is gated on `SMTP_CONFIGURED` instead.

### Managed Auth / Embedding (EE)

Expand Down
Loading
Loading