Authentication, authorization, and hardening
Every request is authenticated and workspace-authorized; credentials and targets are protected by session, CSRF, SSRF, and rate-limit controls that match the self-hosted deployment model.
This is an engineering document. Specs are written for people building DataGripe: they record what a subsystem does and why the alternatives were rejected, and some of them describe things that are not built yet. If you are using DataGripe, the documentation is the place to be — it is maintained for you and this is not.
Supersedes: the pre-auth stub notes in docs/spec/connections.md
(implements docs/initial_idea.md §10, §12; ADR 0002)
Goal
Every request is authenticated and workspace-authorized; credentials and targets are protected by session, CSRF, SSRF, and rate-limit controls that match the self-hosted deployment model.
Non-goals
- SAML, and OIDC providers other than Google (later, behind the same
session contract —
oauth_identitiesalready carries aprovider). - Security keys as a second factor on top of a password (they are an alternative to one; nothing forces both).
- Attestation checks against the FIDO metadata service — registration
asks for
attestationType: "none", so a deployment cannot today restrict which makes of key it accepts. - Multi-workspace switching UI (users belong to one default workspace).
- Workspace invitation emails (members are added by existing owners).
- Connect-time DNS rebinding defense (see SSRF limitations).
Design
Accounts and sessions
- Local email+password accounts (ADR 0002), unless the deployment turned
them off (
PASSWORD_AUTH_DISABLED).users.password_hashusesBun.password(bcrypt). Minimum password length 12. A security-key account has no password at all:password_hashis null and the credentials inwebauthn_credentialsare the whole story. - Signup: allowed while zero users exist (bootstrap), or when
ALLOW_SIGNUP=true(development default; production operators enable it deliberately). The first real user also becomesownerof the migrated stub workspace (Local), inheriting its connections and history. - Sessions: opaque 32-byte tokens, SHA-256 hashed at rest
(
sessions.token_hash), 14-day expiry, hourly sweep of expired rows. Cookiedg_session:HttpOnly,SameSite=Lax,SecurewhenNODE_ENV=production, path/. - Logout revokes the session and closes every socket bound to it.
Security keys (FIDO2 / WebAuthn)
A key is an alternative to the password rather than a second factor on top of it, for both signup and sign-in, and one account may register as many keys as it likes — a spare in a drawer is the point.
- Usernameless sign-in.
POST /api/auth/passkey/login/optionsissues a challenge with an emptyallowCredentials, so the browser offers whatever discoverable credentials it holds; the credential id that comes back names the account on its own, and there is no email to type and none to leak. Registration therefore asks forresidentKey: "required". - User verification is required, at registration and at every sign-in. On a hardware key that means a PIN. For a key-only account the key alone must not be enough, because there is no password behind it.
- Registration is one of two things depending on who is asking.
Signed in (session + CSRF): the key joins that account, with the
account's existing credentials in
excludeCredentialsso the same key cannot be registered twice. Signed out: the request carries the email for a new account, gated by the same bootstrap /ALLOW_SIGNUPrule as password signup — checked when the challenge is issued and again when the credential comes back. - The user handle is the account id. It is decided before the
account exists (a signup ceremony has to hand the authenticator
something) and carried on the challenge row as
pending_user_id; the account is created with that id when the credential verifies, or never, if the ceremony is abandoned. At sign-in the handle the authenticator reports must match the credential's owner. - Challenges live in
webauthn_challenges, expire after 5 minutes, and are deleted by the statement that reads them: one challenge, one verification, whether or not that verification succeeds. The verify call finds its challenge by decoding the client data the authenticator signed — there is no session to keep it in. - Signature counters are stored and must advance. A counter that
fails to move means a cloned authenticator, and the assertion is
refused (
@simplewebauthn/serverenforces this). - Removing the last credential of an account with no password is refused: it is the only way in.
Configuration: WEBAUTHN_RP_ID (defaults to WEB_ORIGIN's hostname),
WEBAUTHN_RP_NAME, WEBAUTHN_EXTRA_ORIGINS. Nothing switches the
feature on — it is available whenever accounts are
(AUTH_DISABLED=false) and the browser is in a secure context.
| Route | Payload | Result |
|---|---|---|
POST /api/auth/passkey/register/options |
{ email? } |
creation options |
POST /api/auth/passkey/register/verify |
{ response, name? } |
the key, or a session cookie for a signup |
POST /api/auth/passkey/login/options |
{} |
request options |
POST /api/auth/passkey/login/verify |
{ response } |
session cookie |
GET /api/auth/passkeys |
– | { passkeys, hasPassword } |
POST /api/auth/passkeys/rename |
{ id, name } |
{ ok } |
POST /api/auth/passkeys/delete |
{ id } |
{ ok } |
Rename, delete and registration onto a signed-in account require the
x-csrf-token header; listing is a read and does not. All of them are
absent (404) when AUTH_DISABLED.
Google sign-in
Optional, and off until a deployment configures an OAuth client:
GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET together switch it on, and
setting exactly one of them is refused at startup rather than quietly
ignored. Like a security key, it is an alternative to the password, not
a factor on top of one.
- Authorization code with PKCE.
GET /api/auth/google/startissues a state, a PKCE verifier and a nonce, seals all three into the short-liveddg_oauthcookie (HMAC overSESSION_SECRET,SameSite=Laxso it survives the trip back), and redirects to Google withscope=openid email— the address is all DataGripe stores. - The callback (
GET /api/auth/google/callback) needs the state it issued, exchanges the code on a backchannel authenticated with the client secret, and checks the id token'siss,aud,expandnonce. The token's signature is deliberately not verified: it came back over TLS from Google's token endpoint rather than through the browser, which is the case OIDC Core §3.1.3.7 exempts. - Identities are matched on
sub, never on the email — people rename their address and the subject does not move. A first sign-in links to the existing account owning that address when Google reportsemail_verified, and otherwise creates one under the same bootstrap /ALLOW_SIGNUPrule as every other signup. An unverified address is refused outright. GOOGLE_ALLOWED_DOMAINS(comma separated) restricts sign-in to Google Workspace domains, matched against thehdclaim and falling back to the email's domain. Empty means any Google account, which withALLOW_SIGNUP=truemeans anyone on the internet — a deployment that opens signup to Google should set this.- Failures come back as a redirect to
WEB_ORIGINwith anauth_errorquery parameter, because a redirect is all a navigation can return; the sign-in screen shows it and scrubs it from the address bar.
Configuration: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI (defaults to WEB_ORIGIN +
/api/auth/google/callback), GOOGLE_ALLOWED_DOMAINS.
| Route | Payload | Result |
|---|---|---|
GET /api/auth/google/start |
– | 302 to Google + dg_oauth cookie |
GET /api/auth/google/callback |
?code&state |
session cookie, or 302 with auth_error |
Roles
Superseded by docs/spec/permissions.md: a role is a project's own
name and set of capabilities, and the three ranks are seeded rows with
the capabilities they always had. What is still true here is where the
check happens — on every message, in the dispatcher, against what the
socket resolved at upgrade.
Which methods a deployment offers
Each method is independently switchable, so a server can be locked to one of them:
| Variable | Effect |
|---|---|
PASSWORD_AUTH_DISABLED=true |
no login/signup routes, no password form |
PASSKEY_AUTH_DISABLED=true |
every /api/auth/passkey* route is absent |
GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET |
Google sign-in appears |
Off means absent — 404, not a form that refuses. GET /api/session
reports passwordAuthEnabled, passkeysEnabled and googleAuthEnabled
so the sign-in screen only renders what exists. Turning off the last way
in fails at startup: a server with accounts and no method to reach them
is only discovered at the login screen otherwise. AUTH_DISABLED=true
is exempt, having no accounts to sign in to.
Removing a security key still refuses to remove the only way into an account — a linked Google identity now counts as one of those ways, alongside a password and a second key.
CSRF and origin checks
GET /api/sessionreturns{ user, workspace, role, csrfToken, wsUrl, bootstrap }. State-changing HTTP routes (login exempt — it has no session yet; logout) require thex-csrf-tokenheader to match the session's token.- HTTP and WebSocket requests validate
OriginagainstWEB_ORIGIN. The WS upgrade additionally requires the session cookie — browser WebSocket APIs cannot attach authorization headers.
WebSocket authorization
Upgrade binds the socket to { userId, sessionId, workspaceId, role }
(the user's default workspace: their first workspace_members row).
Every action handler receives this context and the dispatcher enforces
the role matrix on every message — socket authentication is not
object authorization (initial_idea.md §10).
| Action | viewer | editor | owner |
|---|---|---|---|
workspace.open, schema.children, history.list, execution.subscribe, workspace.members |
✓ | ✓ | ✓ |
execution.start, execution.cancel |
– | ✓ | ✓ |
connection.create/update/delete/test |
– | ✓ | ✓ |
workspace.member.add, workspace.member.remove |
– | – | ✓ |
document.*, layout.save (server sync, later) |
✓ | ✓ | ✓ |
Violations return FORBIDDEN. New actions:
| Action | Payload | Result |
|---|---|---|
workspace.members |
{} |
[{ userId, email, role, since }] |
workspace.member.add |
{ email, role } |
the added member |
workspace.member.remove |
{ userId } |
{} (owners cannot remove the last owner) |
SSRF policy (security/ssrf.ts)
Before opening any target connection (test, introspection, execution),
the hostname is resolved (node:dns) and every returned address is
classified. Blocked by default: loopback, RFC1918, link-local
(including 169.254.169.254 cloud metadata), ULA, multicast,
unspecified, and reserved ranges. TARGET_HOST_ALLOWLIST (comma
separated, exact or *.suffix) overrides per hostname — development
uses localhost,127.0.0.1,::1. SSRF_DISABLED=true bypasses the
policy entirely; intended only for trusted-network deployments, since
the server will then connect to any host, including loopback and cloud
metadata endpoints.
Limitation (accepted): Bun.SQL resolves hostnames itself at connect time, so a DNS answer can change between our check and the driver's connection (rebinding TOCTOU). Full defense requires a connect-time address hook Bun.SQL does not expose; deployments that need it must enforce egress at the network layer.
Rate limits (security/rateLimit.ts)
In-memory token buckets, keyed per scope:
| Scope | Key | Limit |
|---|---|---|
auth.login |
IP | 30/min |
auth.login |
5/min | |
auth.passkey |
IP | 60/min |
connection.test |
user | 10/min |
execution.start |
user | 30/min |
schema.children |
user | 120/min |
Exhaustion returns RATE_LIMITED (HTTP 429 / WS error).
Audit logging
log.audit(event, fields) emits structured records for:
auth.signup, auth.login.success, auth.login.failure,
auth.logout, auth.passkey.register, auth.passkey.register.failure,
auth.passkey.login.failure, auth.passkey.remove,
connection.create/update/delete,
execution.start/cancel, workspace.member.add/remove, and
ssrf.blocked. Never passwords, secrets, or result values. Dispatch
logs every action's duration and outcome at debug level.
Open questions
- Sliding session renewal vs fixed 14-day expiry (currently fixed).
- Per-workspace role selection when users join multiple workspaces.
- Lockout vs rate-limit-only for repeated login failures (currently rate-limit-only; audit events enable alerting).
- Whether an account should be able to add a password after being created with a key alone (today it cannot: the only route to a password is signup).
This page as Markdown, for an agent or a diff.