Repository navigation
Authentication
Authentication runs on Better Auth, backed by
the project's own Prisma/PostgreSQL database. Configuration lives in
src/lib/auth/server.ts.
Gardik used Auth.js (next-auth v5) until the
better_authmigration.AUTH_SECRETandAUTH_URLwere replaced byBETTER_AUTH_SECRETandBETTER_AUTH_URL. See Configuration.
Each sign-in writes a row to the Session table rather than relying on a signed
cookie alone. Sessions can therefore be listed, revoked and audited server-side,
which is what makes Remediation able to cut an exposed employee's access.
| Method | Plugin | Notes |
|---|---|---|
| Email and password | built in | Passwords hashed with bcryptjs, set explicitly on emailAndPassword.password rather than the library default |
| TOTP two-factor | twoFactor |
Per-user enrollment, with backup codes and lockout after repeated failures |
| Email OTP | twoFactor |
Second factor delivered by email |
| Passkeys | @better-auth/passkey |
WebAuthn. A passkey sign-in is complete primary authentication, not a second factor |
| SSO (OIDC) | @better-auth/sso |
One provider per company, with domain verification |
A company chooses which second factors it accepts through
Company.allowedAuthMethods (AuthMethod[], default [TOTP]). See
PATCH /api/company/auth-policy in the API Reference.
Two company-level switches drive the policy, both on the Company model:
-
allowedAuthMethods: which ofTOTP,EMAIL_OTP,PASSKEYmay be used. -
ssoMandatory: when true, local sign-in is refused and members must come through the identity provider.
User.ssoExempt is the anti-lockout valve. An expired IdP certificate must not
lock a whole company out of its own security product, so an exempt user can
still sign in locally while ssoMandatory is on. The rule is one function,
deniesLocalSignIn in src/lib/sso/policy.ts.
The policy is enforced on the passkey path too. A passkey sign-in creates the
session itself, so without an explicit check a company that enabled
ssoMandatory to cut someone's access would still have let them in through the
passkey button.
src/lib/auth/server.ts configures Better Auth; the policy hook lives in
method-policy.ts and the environment switches in server-env.ts.
-
Methods are refused server-side, not hidden. Enrolling TOTP, requesting
an email code and registering a passkey are checked against
allowedAuthMethods. An email code is requested mid-login, before any full session exists, so the company is resolved from the signedtwo_factorcookie the same way Better Auth resolves the pending user. - No timing equalizer on password sign-in. The library already hashes the supplied password when the address is unknown or has no password; adding a second hash measured 590 ms against 350 ms and recreated the difference the other way.
-
Public sign-up is disabled explicitly. It otherwise only failed because
companyIdcannot come from a request body, a schema accident that a later change could silently undo. - Passwords: bcrypt with 12 rounds like every seed script, 12 to 72 characters (bcrypt ignores everything past 72 bytes).
-
mustChangePasswordrides on the session, so the API guard needs no query per request. Setting the flag deletes the user's sessions, so a session saying false was issued after it was cleared. - SSO linking does not require a confirmed local email: a pre-provisioned user has none, and the trust comes from the provider's verified domain.
-
SSO is strictly pre-provisioned (
disableImplicitSignUp, andrequestSignUpis never sent), domain verification is mandatory for linking, and the tenant check runs on every login: on a mismatch the session row is deleted before any cookie is set. -
appNameis "Gardik", which is the issuer an authenticator app shows for the TOTP entry. -
Rate limiting is stored in the database, so the window is shared by every
instance and survives a deploy. It is disabled only when
E2E=1and the base URL is loopback, so a leakedE2E=1in production does nothing; an unset base URL never counts as loopback. -
Test-only trusted origins: under Vitest (
NODE_ENV=test, which nothing else sets)http://127.0.0.1:*is trusted so the SSO plugin's SSRF guard lets the local stub identity provider through. - Passkeys take their relying-party ID and origin from the base URL, so a passkey registered under one host cannot be used under another.
src/lib/auth/two-factor-gate.ts decides whether a signed-in user is routed to
enrollment. It refuses to force enrollment on an account with no password,
because enabling TOTP requires confirming the current password: a
pre-provisioned SSO account has none, so forcing it would strand the user on a
form they cannot satisfy. For those accounts the identity provider owns the
second factor.
The SSO plugin exposes provider management to any authenticated session, which
would let a Viewer enroll an identity provider on their own company. Gardik
gates four plugin paths behind sso:config (src/lib/sso/policy.ts):
/sso/register
/sso/update-provider
/sso/request-domain-verification
/sso/verify-domain
Sign-in and the callback stay open by design, since they are the login path.
Provider records live in SsoProvider, one per company, with domain and
domainVerified. The OIDC client configuration is encrypted at rest through a
Prisma extension (src/lib/sso/encryption.ts), so it is sealed on write and
opened on read without the call sites having to think about it.
POST /api/sso/resolve runs before sign-in: the login page asks whether a typed
email's company has a verified provider, so it can send the user to the right
place. It is deliberately unauthenticated, since there is no session yet.
New users are invited rather than created with a password
(src/lib/auth/invitation.ts):
- The invitation token is single-use and expires after 72 hours
(
INVITATION_TTL_HOURS). - Only a hash of the token is stored, in
UserInvitation. - The invitee sets a password between 12 and 72 characters. The upper bound is not cosmetic: bcrypt truncates at 72 bytes.
An administrator can force a rotation with
POST /api/users/[id]/require-password-change, which sets
User.mustChangePassword. A user in that state is refused by the normal guard
everywhere except POST /api/account/password, which re-verifies the current
password before writing the new one.
A deployed image has no company and no account, and public sign-up is disabled,
so nobody can create one. src/lib/auth/bootstrap.ts, wired through
src/instrumentation.ts, closes that gap at start-up when
BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_INVITE_TOKEN are both set.
It issues an invitation rather than setting a password, so everything above about validation, hashing, verification and the forced second factor applies to it unchanged. The token is supplied by the operator rather than generated, which is what keeps the container from ever printing a secret.
The gate asks whether any account already carries a password, not whether any user exists. The stricter-looking rule is the more fragile one: a link lost before use would lock the operator out permanently. Testing for a password instead lets a restart reissue the invitation, while still closing the door the instant anybody can genuinely sign in.
The other decisions behind it:
-
It runs on every start, and is idempotent. The variables usually stay in
the compose file, so each restart reissues the link with a fresh 24-hour
window (shorter than a colleague's 72 hours: a deployment finishes in one
sitting, and a restart costs nothing). The unredeemed invitation with the same
token is deleted first, because
UserInvitation.tokenHashis unique and the operator's token never changes. -
The audit trail stays true. The user is looked up before the upsert, so a
restart records an invitation, never a second
user.create. - The token is never logged. The operator already holds it, and a log line carrying it would outlive the link in any log aggregator.
- The token must be at least 32 characters, so a guessable value is refused.
- Nothing throws. An unreachable database, a schema not migrated yet or a half-set pair of variables each become one log line: a failed bootstrap must never turn a running instance into a dead one. No variables at all is silent, since that is the normal state of every running instance.
- Replicas race safely. Two replicas booting together collide on a unique constraint; the loser logs "Another replica bootstrapped first" instead of a constraint error.
src/proxy.ts protects every route except api/auth, static assets and
/login. Authorization beyond "is signed in" is permission-based and covered in
Roles and Permissions.
- Roles and Permissions for what a signed-in user is allowed to do
- Audit Log for what gets recorded
- Security for encryption, rate limiting and the CI gates
Gardik is source-available software by Melvin PETIT (WhiteMuush). Live demo, read only, no signup.
Getting started
Architecture
Features
- Breach Scanning
- Risk Scoring
- Directory Integrations
- MFA Coverage
- SCIM Provisioning
- Dashboard and Widgets
- Reports
- Exposure Register
Integrations
Identity and access
Reference
Contributing