OctoWiki

Auth & Access Control

Every way Octopus authenticates and authorizes — JWT (API), cookie sessions (admin), cookie-JWT (client portal), WebAuthn passkeys, TOTP 2FA, IP whitelisting, RBAC, and multi-tenant scoping — plus the security gaps to fix.

Octopus has four independent auth surfaces, each with its own mechanism. They all share one JWT signing secret and one repository-level tenant guard. This page is the map; it also flags a cluster of real security gaps the next maintainer should triage early (see Security findings).

Read this first

Several controls that comments and docs imply exist are not wired up: CSRF is defined but commented out, there is no rate limiting anywhere (including OTP/login), the "automation blocker" middleware does not exist in code, and TOTP secrets are stored in plaintext. Details in Security findings.

The four surfaces at a glance

Route groupMechanismGuard middleware (in order)Wired at
/auth/*Public login/refresh; JWT+IP for logout/cleanupJWTAuthMiddleware, IPWhitelistMiddleware (logout/cleanup only)http/routes/routes.go:50
/api/v1/*Bearer access JWT + IP whitelistjwtAuthMiddleware, ipWhitelistMiddleware (group-level)routes.go:77
/g2a/*OAuth2 client-credentials bearer (Subject g2a)G2AAuthMiddleware() (token endpoint public)routes.go:62
/admin/*Cookie session (Valkey-backed)AdminAuthMiddleware, then AdminWriteMiddleware / AdminSuperUserMiddlewareroutes/admin.go:31
/client/*httpOnly-cookie JWTClientCookieAuthMiddleware on /client/api; /client/auth/* publicroutes/client.go:18
/webhooks/*Vendor-authenticated (signature / path token) — no JWTnoneroutes.go:109

Global stack (main.go:490): panic recovery → otelfiber → tracing → metrics → CORS (web.IsAllowedOrigin, AllowCredentials:true) → helmetCSRF (commented out, main.go:513).

The JWT layer

Core: utils/jwt.go. Algorithm is HS256 (symmetric) — one secret signs everything.

  • Signing secret comes from env JWT_SECRET, with an insecure hardcoded fallback "your-super-secret-key". It is read independently in four middlewares (middleware/auth.go:23,112, client_cookie_auth.go:26, g2a_auth.go:34). No APP_KEY is involved (that key encrypts Shopify/voucher data elsewhere).
  • Claims (JWTClaims): client_id + standard registered claims (jti uuid, exp, iat, Subject). The user id is not in the JWT — it lives only on the auth_tokens DB row.
  • Token types by Subject — cross-surface replay is blocked by strict Subject checks:
SubjectLifetimeUse
access1h/api/v1 + client portal access cookie
refresh7dsingle-use rotating refresh
g2a15mG2A inbound channel
pending_2fa5mclient 2FA gate between password and TOTP
  • Defense-in-depth validation (API path, middleware/auth.go:49): JWT sig/exp → DB lookup in auth_tokensIsRevoked check → DB expiry → UserID != nil ⇒ rejected (portal tokens barred from the machine API) → client-active check. The client-cookie path is the mirror image (UserID == nil ⇒ rejected).
  • Signing-method pin: ValidateToken rejects non-HMAC algorithms — guards against alg=none / HS-RS confusion.
  • Refresh rotation (auth.go:96, client_auth.go:152): single-use. Race safety via RevokeAuthTokenIfActive — an atomic revoke-iff-active claim so concurrent/replayed refreshes mint at most one new pair.
  • Logout: API /auth/logout revokes all tenant tokens; client /client/auth/logout revokes just the current pair and clears cookies.

Every issued token is also persisted to auth_tokens (models.AuthToken) carrying IsRevoked, ExpiresAt, nullable UserID. Revocation and the API-vs-portal split both key off this row — the JWT alone is never trusted.

Client API auth — JWT plus IP whitelist

/api/v1 requires both a valid access JWT and a source IP on the tenant's whitelist. IP check runs after JWT so client_id is known.

  • Enforcement (middleware/ip_whitelist.go:22): loads active ip_whitelist_entries, allow-all if the list is empty, matches the client IP against each CIDR. Client IP is taken from X-Real-IP with the trusted-proxy set limited to 127.0.0.1/::1 (main.go:484) so clients can't spoof it.
  • The whitelist is also enforced at login/refresh (Handler.checkIPWhitelist, auth.go:270) — a near-verbatim clone of the middleware. Keep the two copies in sync.
  • API credentials (client_settings.go:113): per-key username + 32-char random password, bcrypt-hashed at rest, plaintext returned once. /auth/login exchanges username+password (bcrypt verify) for a JWT pair.

The IP whitelist fails open on DB error (ip_whitelist.go:47, auth.go:277) — availability over security, intentional per comments. A whitelist with zero entries allows all IPs. Don't assume "whitelist configured" == "restricted" unless entries exist.

WebAuthn / passkeys (client portal only)

Service services/webauthn.go, handlers http/handler/passkey.go, model passkeys + webauthn_challenges.

  • RP config: WEBAUTHN_RP_ID (default localhost), WEBAUTHN_RP_NAME, WEBAUTHN_ORIGINS (comma-split).
  • Registration: BeginRegistration excludes existing creds, requires resident key + user verification, stores a challenge row (5-min TTL). FinishRegistration verifies, persists the Passkey (credential_id, public_key, aaguid, sign_count, transports).
  • Login (discoverable / usernameless): BeginDiscoverableLogin (UV required) → FinishLogin resolves by RawID, active-check, updates the sign count, then issues the normal cookie JWT pair.
  • Management routes under /client/api/passkeys/*; public begin/finish under /client/auth/passkey/*.

Passkey login bypasses the TOTP 2FA gate that password login enforces (by design — the passkey itself is the second factor). Clone-detection data is captured but CloneWarning is hardcoded false.

TOTP / 2FA

Shared service services/totp.go; admin admin_2fa.go, client client_2fa.go.

  • Params: 30s period, 256-bit secret, 6 digits, SHA1 (authenticator compat), issuer APP_NAME (+ " Admin" for admin).
  • Backup codes: 10 codes, XXXX-XXXX, bcrypt-hashed at rest, one-time use.
  • Admin enrollment: setup-begin stores a pending secret → setup-complete validates a code, enables 2FA, generates backup codes. Disable requires password re-auth. Super-admins can reset another user's 2FA.
  • Login gates:
    • Admin: password OK + 2FA enabled → CreatePending2FASession (Valkey, 5-min, cookie admin_2fa_pending) → /admin/login/2fa verify → real session.
    • Client: password OK → pending_2fa JWT (5-min) cookie scoped to /client/authClientVerify2FAHandler → real tokens.

Two gaps here

TOTP shared secrets are stored in plaintext (repo/client_user.go:485, repo/admin_user.go:428) — backup codes are hashed, the seed is not. And there is no rate limit on TOTP / backup-code verification, so codes are brute-forceable within each 30s window.

Admin sessions

Service services/admin_session.go.

  • Cookie admin_session: 256-bit random id, Path=/admin, HTTPOnly, SameSite=Strict, Secure=false hardcoded (comment says "set true in prod" but it is not env-gated — unlike client cookies).
  • Storage: primary store is Valkey (admin:session: prefix, 24h TTL); a DB admin_sessions row is also written for audit (non-fatal on failure).
  • Lifetime: 24h sliding — RefreshSession extends on each authenticated request; ShouldRefresh after 1h idle.
  • Session payload carries role, IsSuperAdmin, TwoFactorEnabled, and a session-only ViewOnlyMode flag.
  • Pending-2FA sessions use a parallel admin:2fa_pending: prefix, 5-min TTL.

Client portal auth & password reset

Handlers client_auth.go, client_auth_password_reset.go.

  • Password hashing: bcrypt. Cost mismatch — client/API use DefaultCost 10 (jwt.go:150), admin uses 12 (admin_auth.go:196).
  • Cookies: octopus_access (Path=/client) + octopus_refresh (Path=/client/auth), both httpOnly. Secure/SameSite/Domain are env-gated — production ⇒ Secure=true, SameSite=Lax, Domain=COOKIE_DOMAIN.
  • Password reset (client_auth_password_reset.go): anti-enumeration (always generic response); token is 256-bit random, SHA-256 hash stored (raw emailed), 30-min TTL, single live token per user; the token is consumed before the password write (guarded by used_at IS NULL) to prevent replay.

Authorization (RBAC)

Admin (models/admin_user.go): roles viewer / admin / super_admin + boolean IsSuperAdmin.

  • SessionData.CanWrite(): ViewOnlyMode → false; super-admin → true; else role must be admin. Enforced by AdminWriteMiddleware on every write route.
  • AdminSuperUserMiddleware gates user-management routes. AdminRoleMiddleware(...) exists but appears unused (dead code).
  • View-only mode is a self-service session toggle, not a persisted permission.

Client portal (models/client_user.go): roles owner / admin / write / read, surfaced as capabilities (CanCreateOrders, CanManagePayouts, CanManageUsers, CanAccessBilling).

  • Enforced by RequireClientPermission(perm) on order/cart/payout-creating routes. Unknown permission ⇒ deny.
  • ⚠️ ClientGetMeHandler returns all-true permissions when no user record resolves (client_auth.go:376) — display-only (real enforcement uses the locals user), but misleading.

Multi-tenant scoping

The primary tenant-isolation mechanism is at the repository layer, not the handler:

  • Every auth middleware sets c.Locals("client_id") and injects types.TenantIDKey into the request context.
  • Repos read it via IsClientContext(ctx) (repo/repo.go:253) and append WHERE client_id = ? (e.g. repo/transaction.go:29,76,123, cart, cart_item).
  • Admin context has no tenant id → admin queries are intentionally unscoped.

Scoping is opt-in per query. Any repo method that forgets the IsClientContext guard leaks across tenants. This is worth a dedicated audit — every client-reachable repo method must apply the guard.

Security findings

Surfaced during the code read. Ranked; all are in-repo (nginx/infra layer not audited here).

SeverityFindingWhere
HighCSRF disabledCSRFConfig() exists but is commented out (main.go:513). Templates render csrf tokens that are never validated. Admin panel relies solely on SameSite=Strict.main.go:513, middleware/csrf_middleware.go
HighTOTP secrets stored plaintext at rest (backup codes are hashed; seeds are not).repo/client_user.go:485, repo/admin_user.go:428
HighNo rate limiting anywhere — none on OTP/2FA, client login, forgot-password, or refresh. No limiter middleware exists in code.codebase-wide
HighJWT secret falls back to hardcoded "your-super-secret-key" when JWT_SECRET is unset — no startup assertion.utils/jwt.go + 4 middleware sites
Medium"Automation blocker" middleware does not exist — comments reference it (routes.go:60) but no such middleware is in the codebase. Either removed with stale comments, or never built.routes.go:60 + comments
MediumAdmin session cookie Secure=false hardcoded (not env-gated).services/admin_session.go:55
MediumNo client-side login lockout — admin has a 5-strike / 15-min lock (admin_auth.go:33); client login has no equivalent.client_auth.go:18
Lowbcrypt cost mismatch: admin 12 vs client/API 10.admin_auth.go:196 vs jwt.go:150
LowDead/misleading CookieAccessExpiry=15m vs actual 1h access token.config/cookie.go:17
LowcheckIPWhitelist logic duplicated in handler and middleware.auth.go:270 / ip_whitelist.go:22
LowAdminRoleMiddleware appears unused.admin_middleware.go:188

Cross-references

The automation-blocker rule in CLAUDE.md describes a production control (X-Automation-Request blocking) — the research above found no matching middleware in this codebase. Verify whether it lives at the nginx layer or has been dropped before relying on it. See also Secrets & Config for where JWT_SECRET, APP_KEY, and cookie config are sourced.

Key files

  • Middleware: middleware/{auth,client_cookie_auth,ip_whitelist,g2a_auth,csrf_middleware}.go; admin session/role middleware in http/handler/admin_middleware.go
  • JWT/crypto: utils/jwt.go; cookie config config/cookie.go
  • Services: services/{webauthn,totp,admin_auth,admin_session}.go
  • Handlers: http/handler/{auth,admin_auth,client_auth,client_auth_password_reset,admin_2fa,client_2fa,passkey,client_ip_whitelist,client_settings,admin_users}.go
  • Models: database/models/{auth_token,admin_user,client_user,passkey,ip_whitelist}.go

On this page