This document defines the smallest authentication design needed by the current API.
The central rule is:
The token identifies the user; the database determines the organization; the organization scopes every tenant-owned operation.
Authentication answers “who made this request?”. Authorization answers “may that user operate on this organization’s data?”.
The initial implementation provides:
- public organization registration with one initial user;
- password-based login;
- short-lived JWT access tokens;
- a request-scoped authenticated actor;
- tenant identity derived from authentication rather than GraphQL input;
- tenant-scoped repository lookups.
Every user belongs to exactly one organization. Roles and permissions are not part of this first implementation.
Authorization: Bearer <token>
↓
GraphQL context
- verifies the token
- loads the user by token subject
- creates AuthenticatedActor
↓
Resolver
- requires an actor for protected operations
- calls the use case
↓
Use case
- obtains organizationId from the actor
↓
Repository
- scopes tenant-owned queries by organizationId
The JWT contains the stable user ID in sub. It does not need to contain organizationId. Loading the user on each request makes current organization membership the source of truth.
src/
├── domain/
│ └── User.ts
├── application/
│ ├── auth/
│ │ └── AuthenticatedActor.ts
│ ├── ports/
│ │ ├── UserRepository.ts
│ │ ├── PasswordHasher.ts
│ │ ├── AccessTokenService.ts
│ │ └── OrganizationRegistrationStore.ts
│ └── use-cases/
│ ├── RegisterOrganization.ts
│ └── Login.ts
├── infra/
│ ├── auth/
│ │ ├── Argon2PasswordHasher.ts
│ │ └── JoseAccessTokenService.ts
│ └── database/
│ ├── prisma-user-repo.ts
│ └── prisma-organization-registration-store.ts
└── delivery/
└── graphql/
├── context.ts
└── modules/
└── auth.ts
tests/
├── application/
│ └── use-cases/
├── delivery/
│ └── graphql/
└── database/
Application tests can define small fakes directly in the test file. There is no need to build a shared in-memory database or reproduce Prisma transactions in memory.
User is the persistent identity entity:
type UserState = {
id: string;
organizationId: string;
email: string;
passwordHash: string;
createdAt: Date;
};Raw passwords and registration input types do not belong in User.ts. They belong to the registration use case. The Prisma-generated user is mapped to this entity by PrismaUserRepository.
AuthenticatedActor is trusted request state, not a persistent entity or GraphQL input:
type AuthenticatedActor = {
userId: string;
organizationId: string;
};Protected use cases receive it explicitly:
execute(actor: AuthenticatedActor, input: CreateStockMovementInput)They do not receive the entire GraphQL context.
The application owns small interfaces for external security operations:
interface PasswordHasher {
hash(password: string): Promise<string>;
verify(hash: string, password: string): Promise<boolean>;
}
type AccessTokenClaims = {
userId: string;
};
interface AccessTokenService {
create(claims: AccessTokenClaims): Promise<string>;
verify(token: string): Promise<AccessTokenClaims>;
}Argon2 and JOSE remain infrastructure details behind these ports.
JWT_SECRET is required runtime configuration. The composition root validates
it at startup and injects it into JoseAccessTokenService; the infrastructure
adapter does not read process.env directly. HS256 requires a strong signing
secret, so shared environments use at least 32 cryptographically random bytes.
The composition root constructs the infrastructure implementations and passes them to UseCases. PasswordHasher and AccessTokenService are supplied separately because they are security services, while OrganizationRegistrationStore remains part of the Repositories dependency as repos.registrations:
new UseCases({
repos,
passwordHasher,
accessTokenService,
});This was a deliberate simplification: all persistence adapters are grouped in one object for use-case dependency wiring, even though OrganizationRegistrationStore is a transactional persistence port rather than a conventional single-entity repository. RegisterOrganization still receives the narrow store interface directly; only the composition-level grouping is shared. A framework container might otherwise perform this dependency wiring implicitly.
RegisterOrganization accepts organization details, initial-user email, and the raw password. It:
- validates and normalizes input;
- hashes the password;
- creates the
OrganizationandUserentities; - asks
OrganizationRegistrationStoreto persist both atomically; - returns a small result containing the new IDs.
Normalization must be consistent during registration and login, for example email.trim().toLowerCase().
The persistence boundary is:
interface OrganizationRegistrationStore {
register(organization: Organization, initialUser: User): Promise<void>;
}Its Prisma implementation uses one nested organization.create() with users.create. Prisma makes that nested write atomic: either both records are created or neither is.
Database unique constraints are authoritative. An application pre-check may improve the normal error path, but it cannot prevent concurrent registrations. Prisma unique-constraint failures must also become a controlled duplicate-email error.
Registration is exposed through a public GraphQL mutation in delivery/graphql/modules/registration.ts:
The resolver validates the input with Zod and calls context.useCases.registerOrganization.execute(input). It does not require an authenticated actor, hash passwords, or call Prisma directly. Registration returns IDs; clients use the separate login mutation to obtain an access token.
Registration is the only public organization provisioning path. The former
unrestricted createOrganization mutation has been removed.
Login:
- normalizes the email;
- loads the user with
UserRepository.findByEmail(); - verifies the password hash;
- returns the same public credentials error for an unknown email and a wrong password;
- issues an access token with
sub = user.id.
The token must not contain passwords or other sensitive user data.
The current static context becomes a per-request asynchronous context factory. It:
- reads and parses
Authorization: Bearer <token>; - verifies the signature, algorithm, expiry, issuer, and audience;
- loads the user identified by
sub; - creates
AuthenticatedActorfrom that user; - otherwise an absent header produces
actor: null; malformed/invalid tokens and deleted users should produceUNAUTHENTICATED.
A small requireAuthenticatedActor(context) delivery helper returns the actor or throws a controlled UNAUTHENTICATED GraphQL error. Resolvers do not parse tokens individually.
GraphQL clients must not choose tenant identity. Tenant-owned inputs therefore omit organizationId:
input CreateStockMovementInput {
storageAreaId: ID!
# other operation data
}The use case derives it from actor.organizationId.
Repositories for tenant-owned resources use both values:
findById(organizationId, id);They return the same not-found result when a resource is absent or belongs to another organization. Existing composite PostgreSQL constraints remain defence in depth for relational writes; they do not replace authorization of reads.
Identity lookups are exceptions to tenant scoping: login finds a user by globally unique email, and context loads a user by globally unique ID.
The initial public contract needs:
UNAUTHENTICATEDfor missing, invalid, or expired credentials;NOT_FOUNDfor an absent or foreign-tenant resource;- a controlled invalid-credentials error for login;
- a controlled duplicate-email error for registration.
FORBIDDEN becomes useful when the application introduces an actual permission rule, such as administrator-only user invitations.
See suggested tests
In-memory tests prove application decisions. PostgreSQL tests prove Prisma queries, unique constraints, foreign keys, and rollback.
Registration, login, token issuance, and the per-request GraphQL context are already in place. Tenant authorization should now be introduced one complete operation at a time. Each operation should be green across the delivery, application, repository, and test layers before starting the next one.
Do not protect the entire /graphql HTTP endpoint. The schema contains both
public and protected operations:
registerOrganization,login, andhealthremain public;- tenant-owned mutations and queries require an authenticated actor.
Protected-operation tests use authenticated fixtures and do not create tenants through a second provisioning mutation.
- Add
requireAuthenticatedActor(context)to the GraphQL delivery layer. It returnscontext.actoror throws the controlledUNAUTHENTICATEDerror. - Call this helper before parsing the input of a protected resolver. This makes missing authentication the first failure and avoids exposing validation details to an unauthenticated caller.
- Extend the GraphQL test helper so a request can include headers.
- Let resolver tests supply a small fake token service and user repository that produce a known actor from a known token.
- Keep token verification details in the existing context and JOSE tests. Resolver tests only need to prove that a protected resolver requires and forwards the actor.
The test fixture only needs to represent this flow:
Authorization: Bearer valid-token
↓
AccessTokenService.verify()
↓
userId
↓
UserRepository.findById()
↓
AuthenticatedActor
It does not require a shared in-memory database, registration, password hashing, or a real JWT.
Start with CreateProduct rather than CreateStockMovement. Product creation is
already wired through GraphQL, the application layer, and both repository
implementations, so it provides the smallest complete example of the pattern.
-
Define a use-case-specific input that omits tenant identity:
type CreateProductInput = Omit<ProductInput, "organizationId">;
-
Change the protected use-case signature to:
execute(actor: AuthenticatedActor, input: CreateProductInput)
-
Construct the product with
organizationId: actor.organizationId. -
Keep
organizationIdon the domain entity. It is the untrusted delivery and use-case input that must omit it, not the domain state. -
Update application tests to pass a plain
AuthenticatedActorfixture directly. Do not create a GraphQL context or token in use-case tests. -
Remove the use-case test for an empty client-supplied organization ID because that field is no longer part of the input.
-
Change the supplier lookup contract to include tenant identity:
findById(organizationId: string, id: string);
-
Update the in-memory adapter to check both values. It must not merely accept
organizationIdand then ignore it. -
Update the Prisma adapter to query through the existing compound unique key for
(organizationId, id). -
Make
CreateProductpassactor.organizationIdto the lookup. -
Return the same
ProductSupplierNotFoundErrorwhen the supplier is absent or belongs to another organization. -
Remove
ProductOrganizationIsNotAcceptedError; a correctly scoped repository never returns a foreign supplier. -
Add a Prisma repository test proving that a supplier cannot be loaded using another organization's ID.
-
Remove
organizationIdfrom the GraphQLCreateProductInputdefinition. -
Remove
organizationIdfrom the corresponding Zod schema. -
Require the actor in the resolver and pass it explicitly to
CreateProduct. -
Keep
organizationIdon the returnedProducttype; clients may observe the tenant identity but may not choose it. -
Add resolver tests proving that:
- a request without credentials returns
UNAUTHENTICATED; - the use case is not called without an actor;
- a valid request calls the use case with the authenticated actor;
- the use-case input does not contain
organizationId; - attempting to submit
organizationIdis rejected by GraphQL; - missing and foreign-tenant suppliers both become
NOT_FOUND.
- a request without credentials returns
Complete and commit the whole product slice before changing another operation.
Apply the established pattern separately to CreateSupplier and then
CreateStorageArea:
- Add
AuthenticatedActorto the use-case signature. - Remove
organizationIdfrom the use-case input. - Derive
organizationIdfrom the actor when constructing the domain entity. - Remove
organizationIdfrom the GraphQL input and Zod schema. - Require and forward the actor in the resolver.
- Add missing-authentication and actor-forwarding resolver tests.
- Add application tests proving that the saved entity uses the actor's organization.
The context builds an actor from a persisted user whose organization membership is protected by a database foreign key. These creation use cases therefore do not need to load the organization merely to rediscover that it exists.
Finish and commit one operation before starting the next.
Once the smaller slices establish the pattern:
- Add
CreateStockMovementto the composedUseCasesobject and GraphQL context if it is not yet wired. - Add its GraphQL module and mutation without exposing
organizationIdas input. - Change the use case to accept
AuthenticatedActorand derive the movement organization from it. - Scope the storage-area lookup by
(actor.organizationId, storageAreaId). - Load or validate products only within
actor.organizationId. - Assign the actor's organization to both the stock movement and every movement line.
- Return the same
NOT_FOUNDresult for absent and foreign storage areas or products. - Test mixed-tenant movement lines and verify that no movement is persisted when any referenced product is unavailable to the actor.
For every tenant-owned query, update, and deletion:
- Require an authenticated actor at the resolver.
- Pass the actor explicitly to the use case.
- Scope repository operations by both
organizationIdand resource ID. - Do not load a resource globally and authorize it afterwards when the database can perform the scoped lookup directly.
- Use
NOT_FOUNDfor both missing and foreign resources. - Scope updates and deletions as well as reads. Database constraints alone do not authorize an operation.
After every protected-operation test has authenticated setup:
- Remove the public
createOrganizationmutation. - Remove
CreateOrganizationfrom the composed use cases and GraphQL context. - Remove its obsolete error mappings and tests.
- Keep the
OrganizationGraphQL output type if other schema fields use it. - Confirm that
registerOrganizationis the only public organization provisioning path.
Run the focused tests while developing each layer, then run all checks before committing a completed operation:
npm run typecheck
npm test
npm run lint
npm run test:databaseThe database suite requires its PostgreSQL test-container environment. Unit and GraphQL tests should continue to use narrow fakes rather than reproducing Prisma or transactions in memory.
Defer these until a concrete requirement needs them:
- shared in-memory database;
- generic Unit of Work;
- roles and permission frameworks;
- refresh-token rotation and revocation;
- email verification and password reset;
- organization invitations;
- MFA, passkeys, or OAuth providers;
- PostgreSQL row-level security;
- asymmetric JWT keys and key rotation.
Rate limiting and abuse protection are required before exposing public authentication to untrusted internet traffic, but they do not need to block the initial local learning implementation.