Skip to main content

Architecture: Authentication & Authorisation

Status: Active Last updated: 2026-05-01 Audience: AI agents and engineers debugging auth flows, adding new auth methods, or hardening permissions.


1. Purpose

Every Ever Works API request crosses one of three authentication front-doors and resolves to an AuthenticatedUser. This spec covers how those front-doors compose, how OAuth providers plug in, where permissions are enforced (vs where the platform leaves them to the caller), and how the CLI / MCP server / dashboard flows differ.

2. The Front Doors

Front doorHeaderSpecUsed by
JWT BearerAuthorization: Bearer <jwt>The dashboard after loginWeb UI
API keyx-api-key: ew_live_… or Authorization: Bearer ew_live_…features/api-keys/specCI, CLI, MCP server
OAuth callback?code=...&state=... on /api/auth/<provider>/callbackThis spec, §5First-party login (GitHub, Google)
Device flowPolling against /api/auth/device/...This spec, §6The public CLI on first login

The auth guard fans incoming requests out to the right validator based on the value's prefix and falls through to JWT when nothing else matches. Anything that fails all of them returns 401.

3. The Module Layout

apps/api/src/auth/
├── auth.module.ts
├── controllers/ # /api/auth/* HTTP surface
├── decorators/
│ ├── public.decorator.ts # @Public() — opt out of auth
│ └── user.decorator.ts # @CurrentUser() — inject AuthenticatedUser
├── guards/
│ └── auth-session.guard.ts # The composite guard fanning out to all front doors
├── providers/ # OAuth provider abstractions
│ ├── auth-provider.abstract.ts
│ ├── auth-provider.constants.ts
│ ├── auth-provider.service.ts
│ ├── auth-provider.types.ts
│ ├── auth-runtime.instance.ts
│ ├── auth-sync.service.ts
│ └── request-headers.ts
├── services/
│ ├── auth.service.ts # Issuance, validation, user resolution
│ ├── api-key.service.ts # API-key CRUD + auth (see api-keys spec)
│ └── social-auth.service.ts # OAuth-provider-driven sign-in
├── dto/ # Login / register / OAuth callback DTOs
├── config/ # Auth-related config validation
└── types/

4. JWT Issuance

JWTs are issued by AuthService after one of:

  • Email + password registration → email verification → POST /api/auth/login.
  • OAuth sign-in → provider exchanges code → user resolved or created → JWT issued.

The token payload includes:

ClaimSource
subUser UUID
emailUser email
iatIssued-at Unix timestamp
expExpiry (default: 7 days, configurable)
rolesPlatform roles (user, admin)

There are no per-work roles in the JWT — those are resolved fresh on every request via WorkOwnershipService so role changes propagate immediately (see features/work-members/spec).

Refresh tokens are stored server-side keyed by userId. Refresh exchanges old → new and rotates the refresh token (one-time-use).

5. OAuth Providers

OAuth is abstracted behind auth-provider.abstract.ts's AbstractAuthProvider. Each provider (GitHub, Google) is a NestJS service implementing:

abstract class AbstractAuthProvider {
abstract readonly id: string; // 'github' / 'google'
abstract getAuthorizationUrl(state: string): string;
abstract exchangeCode(code: string): Promise<OAuthTokens>;
abstract fetchUserProfile(tokens: OAuthTokens): Promise<OAuthProfile>;
abstract refreshTokens(refresh: string): Promise<OAuthTokens>;
}

AuthProviderService is the registry — it returns the right provider for a given id, surfaces them at /api/auth/<id>/start and /api/auth/<id>/callback, and stores the resulting OAuth tokens in the oauth_tokens table for later use by GitFacadeService / Octokit.

Important: OAuth tokens are dual-purpose. When a user signs in via GitHub OAuth, the same tokens are saved against their account so the platform can clone their data repos later. Disconnecting an OAuth provider revokes both the login binding and the git access.

SocialAuthService handles the post-OAuth flow:

  1. Match OAuthProfile.email against existing users.
  2. If found, attach the OAuth account to that user (one user can have multiple linked OAuth accounts).
  3. If not found, create the user and attach the OAuth account.
  4. Issue JWT.
  5. Store OAuth tokens in oauth_tokens keyed by (userId, providerId) — encrypted with the same envelope as plugin secrets.

6. Device Auth Flow (CLI)

The public CLI uses the OAuth device authorisation grant to log in without a browser redirect:

  1. CLI: POST /api/auth/device/start → server returns {deviceCode, userCode, verificationUri, interval, expiresIn}.
  2. CLI: shows the user the userCode and instructs them to open verificationUri in a browser.
  3. CLI: polls POST /api/auth/device/poll {deviceCode} every interval seconds.
  4. Browser: user logs in normally and approves the device code.
  5. Server: marks the device code as approved.
  6. Next CLI poll: server returns {jwt, refreshToken}.

This is implemented by an IDeviceAuthProviderPlugin implementation (today only the platform's first-party device-auth provider, but the plugin contract leaves room for external IDP integrations). See the Plugin SDK for the capability shape.

7. The Composite Guard

AuthSessionGuard is registered globally and runs on every controller unless the route has @Public(). It implements:

async canActivate(ctx: ExecutionContext): Promise<boolean> {
if (this.reflector.get(Public, ctx.getHandler())) return true;

const req = ctx.switchToHttp().getRequest();

// 1. API key path
const apiKeyHeader = req.headers['x-api-key']
?? extractBearer(req.headers.authorization);
if (apiKeyHeader?.startsWith('ew_live_')) {
const auth = await this.apiKeyService.authenticate(apiKeyHeader);
if (!auth) throw new UnauthorizedException();
req.user = auth;
return true;
}

// 2. JWT path
const jwt = extractBearer(req.headers.authorization);
if (!jwt) throw new UnauthorizedException();
const claims = await this.authService.validateJwt(jwt);
req.user = { userId: claims.sub, email: claims.email, roles: claims.roles };
return true;
}

The shape stored on req.user is AuthenticatedUser. Controllers access it via the @CurrentUser() decorator.

8. Decorators

DecoratorEffect
@Public()Skips the guard entirely. Used on /api/auth/login, /api/auth/register, callbacks, /api/health.
@CurrentUser()Parameter decorator that injects AuthenticatedUser (or throws if missing).
@Roles('admin')Class/method-level roles guard layered on top of AuthSessionGuard (when auth-roles.guard is registered).

@CurrentUser({ optional: true }) returns null instead of throwing — used on routes that behave differently for anonymous vs authed visitors.

9. Per-Resource Authorisation

AuthSessionGuard only proves who the caller is. What they can do to a specific resource is enforced separately by domain services:

  • WorkOwnershipService.ensureCanRead / ensureCanEdit / ensureCanManage — see features/work-members/spec.
  • SubscriptionService.ensureWithinPlan — for plan-gated operations.
  • ApiKeyService — manages API keys; rejects API-key-authenticated requests on its own endpoints (JWT-only by design).

This split makes it possible for an authenticated user to call an endpoint they're allowed to call (auth pass) but get 403 because of their per-work role (authorisation fail). The dashboard surfaces both as distinct error states.

10. API-Key vs JWT Differences

BehaviourJWTAPI key
Lifetime7 days (refreshable)Until revoked or expiresAt
Per-work roleResolved live from DBSame — keys carry the user's full per-work rights
Manage other API keysAllowedRejected (403)
Manage own account / profileAllowedRejected (403)
Connect OAuth providersAllowedRejected (403)
Trigger work generationAllowedAllowed
Run MCP tool callsN/AThe MCP server's only auth path
Dashboard sessionsYesNo

API keys are deliberately second-class for management endpoints — they can do work but can't bootstrap their own access.

11. Token Storage

TokenStorageEncryption
JWTBrowser cookie / Authorization headerNone at rest (signed only)
Refresh tokenrefresh_tokens tableHashed (one-way)
API keyapi_keys table — see api-keys specSHA-256 hash + display prefix
OAuth access tokenoauth_tokens tableAES-256-GCM (same envelope as plugin secrets)
OAuth refresh tokenoauth_tokens tableAES-256-GCM
Device-flow codedevice_auth_codes tableNone at rest (short TTL)

Encryption keys are configured via PLUGIN_SECRETS_ENCRYPTION_KEY (see Settings System §5).

12. Auth Sync

AuthSyncService mirrors provider-side state (e.g. revoked GitHub OAuth grants) into platform state:

  • On login, the server checks whether the OAuth refresh token still works. If not, the linked account is marked disconnected.
  • A periodic job re-validates connected accounts and surfaces "git provider needs reconnection" banners on the dashboard before users hit failing generation runs.

13. Public Endpoints

The handful of endpoints that bypass auth entirely:

EndpointReason
POST /api/auth/loginLogin itself
POST /api/auth/registerRegistration
POST /api/auth/refreshRefresh-token exchange
POST /api/auth/forgot-passwordPre-login password reset
POST /api/auth/reset-passwordPre-login password reset
GET /api/auth/<provider>/startOAuth start
GET /api/auth/<provider>/callbackOAuth callback
POST /api/auth/device/startCLI device-flow start
POST /api/auth/device/pollCLI device-flow poll
POST /api/auth/verify-emailEmail verification
GET /api/healthLiveness probe

Everything else requires authentication.

14. Testing

apps/api/test/ contains Supertest e2e suites for every front door plus the device flow. Per-work authorisation is covered by both e2e and the work-ownership.service.spec.ts unit suite. Mocking strategy:

  • Unit tests mock AuthSessionGuard to inject a known AuthenticatedUser.
  • e2e tests issue real JWTs via AuthService.issueTestJwt(...) to avoid logging in through the full flow.
  • OAuth providers are mocked at the AbstractAuthProvider level with stubbed exchangeCode / fetchUserProfile.

15. Constitution Reconciliation

PrincipleHow auth respects it
I — Plugin-firstOAuth providers and device-auth providers are plugins via the SDK contracts.
II — Capability-drivenoauth and device-auth-provider capabilities resolve through facades.
III — Source-of-truth reposOAuth tokens are platform-side credentials, stored in DB.
IV — Trigger.devAuth-sync runs as a Trigger.dev task.
V — Forward-only migrationsAll auth tables additive over time.
VI — TestsEvery front door + every provider covered.
VII — Secret hygieneOAuth tokens encrypted; JWT signed only; API keys hashed.
VIII — Plugin countsN/A.
IX — Behaviour-firstThis spec describes observable behaviour.
X — Backwards-compatNew providers plug in without breaking JWT / API-key flows.

16. References