Authentication
Overview
Authentication in Crewship is the gate every other surface sits behind — agents, credentials, journal, runs, even the onboarding wizard run inside a session, and every subsystem trusts the user identity stamped on the request without re-checking. The auth layer therefore has to handle four distinct entry points: an email/password sign-in for the web UI, a Google OAuth flow for SSO installs, an out-of-band password recovery path for accounts that have lost their credential, and a token-and-pairing handshake for thecrewship CLI to authenticate against the same server.
The implementation funnels every entry point through one router file (internal/api/router_auth.go) so the entire surface can be audited at once. Routes split into three trust tiers: public bootstrap endpoints (signup, /bootstrap, OAuth callbacks, password-reset redemption) that establish a session cookie; session-authed endpoints (session list/revoke, CLI token issuance, pairing start/poll) that act on the caller’s own identity; and the NextAuth-compatible /api/auth/* shim that lets the Next.js frontend treat Crewship as a drop-in NextAuth provider. CLI tokens are persisted as bcrypt hashes; passwords likewise — neither is recoverable from the database.
Two safety properties are load-bearing and worth knowing before changing this code. First, /forgot always returns 200 regardless of whether the email matches a real account, so the endpoint cannot be used to enumerate users; real-vs-fake is signalled only by what arrives in the user’s inbox. Second, the reset-link origin is CREWSHIP_PUBLIC_URL and never r.Host — the latter is attacker-tainted via the Host header and would otherwise let a POST /forgot mail the victim a working reset link pointing at an attacker-controlled domain. The handler refuses to send reset mail if CREWSHIP_PUBLIC_URL is unset or malformed, on the principle that a broken email is strictly safer than a hijackable one.
When to use it
Reach for this guide when you’re configuring, debugging, or extending an authentication path — not for ordinary login flows, which Just Work via the web UI:- You’re setting up SSO for an organisation. Google OAuth needs
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETplus a redirect URI registered with the Google Cloud project. The auth router auto-disables the/api/v1/auth/google/*endpoints when either env var is unset, so a half-configured install fails closed rather than half-open. - You’re enabling in-band password recovery. The mailer (
internal/mailer/) readsRESEND_API_KEYandRESEND_FROMat startup; without them,/forgotstill returns 200 but nothing is sent and users must recover via the Admin CLI.CREWSHIP_PUBLIC_URLmust also be set to a validhttp(s)origin or the handler refuses to mail reset links. - You’re issuing long-lived CLI tokens for scripts, CI, or another machine without an interactive browser. Tokens are minted via
POST /api/v1/auth/cli-tokenwhile session-authed, persisted as bcrypt hashes, and revocable from Settings → Sessions or viaDELETE /api/v1/auth/cli-tokens/{id}. - You’re pairing the
crewshipCLI on a new dev machine. The device-code handoff produces the same token type as the in-app issuer but with the convenience that the user never has to copy-paste a secret — see the dedicated CLI Pairing guide for the device-code flow itself. - You’re plugging the Next.js frontend (or another NextAuth client) into Crewship. The
/api/auth/*shim implements the NextAuth contract — CSRF, providers, session, callback/credentials, token refresh, signin/signout, error — so a drop-innext-authclient can treat Crewship as a regular provider without bespoke glue. - You’re investigating a “can’t log in” report. Order of checks:
crewship admin list-users(the row exists with the email casing the user typed?), session cookie present in the browser request, mailer logs (was a reset link sent?),auth_recovery.goerror logs for token-redemption failures.
/forgot flow above is the answer; for an admin locked out at the host level, jump to the Admin CLI guide instead.
Key concepts
| Term | What it means here |
|---|---|
| Bootstrap | First-server-start endpoint (POST /api/v1/bootstrap) that creates the initial OWNER from a fresh database. Idempotent: refuses to mint a second admin if one already exists. The web wizard at /bootstrap is the human surface; CI installs hit the endpoint directly. |
| Session | A server-side row in the sessions store, identified by a cookie set on sign-in. Listable + revocable by the owning user via GET /api/v1/auth/sessions and POST /api/v1/auth/sessions/{id}/revoke. Backs every authed HTTP request. |
RequireAuth middleware | The mux wrapper (r.authMw.RequireAuth) that gates every non-public route. Reads the session cookie, hits the sessions store, attaches the user to the request context via UserFromContext. Endpoints below this never re-check auth. |
| Bcrypt hash | The on-disk form of every password and every CLI token. Cost factor is held constant across signup, admin-CLI reset, and pairing redemption so the verifier doesn’t have to special-case origin. |
/forgot non-enumeration | The recovery flow always returns 200 regardless of whether the email matches a real user. Real vs fake is signalled only by what (if anything) arrives in the user’s inbox — never by HTTP status or response shape. |
| Reset token | A 30-minute single-use token mailed via Resend on a successful /forgot. Stored hashed in password_reset_tokens; redemption (POST /api/v1/auth/reset) invalidates all the user’s active sessions atomically with the password write. |
CREWSHIP_PUBLIC_URL | The authoritative origin for reset-link URLs. Parsed once at handler construction; if unset or malformed, the mailer refuses to send rather than falling back to the Host header (which is attacker-tainted via Host-header injection on /forgot). |
| CLI token | A long-lived API credential (POST /api/v1/auth/cli-token) that the crewship binary stores in ~/.crewship/cli-config.yaml. Bcrypt-hashed at rest; revocable by the issuing user. |
| Pairing code | An 8-character Crockford-base32 code with a 10-minute TTL produced by POST /api/v1/auth/pair/start. The CLI redeems it on POST /api/v1/auth/pair/redeem (unauthed by design — the code is the credential) and gets back a fresh CLI token. See CLI Pairing. |
| Google OAuth handler | Optional. The /api/v1/auth/google/* routes only mount when both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set; /api/v1/auth/google/status always responds so the frontend can decide whether to render the “Sign in with Google” button. |
| NextAuth shim | The /api/auth/* set of endpoints (csrf, providers, session, callback/credentials, token/refresh, signin, signout, error) that implement the NextAuth client contract. Lets the Next.js frontend treat Crewship like any other NextAuth provider with no custom glue. |
| Mailer | The interface in internal/mailer/. mailer.NewFromEnv() returns the Resend implementation when RESEND_API_KEY is set, or mailer.Disabled (which returns ErrDisabled on every Send call) otherwise. The disabled mailer is a feature, not a bug — it keeps /forgot non-enumerable on installs that haven’t configured email. |
Usage
The end-to-end operator path from a fresh binary to working login + recovery is six configuration steps and one bootstrap call. None of them are mandatory after the first — once the env vars are set and the admin exists, day-to-day sign-in needs nothing else.1. Set the public origin
Required if you intend to enable in-band password recovery. This is the URL the reset email links to and the OAuth callback origin Google will see./forgot still returns 200 to preserve non-enumeration).
2. Configure the mailer (optional)
mailer.Disabled — installs without email still work, but users can’t recover passwords in-band. They must instead use Admin CLI reset-password from the host shell.
3. Configure Google OAuth (optional)
/api/v1/auth/google/redirect and /callback only when both are present. /api/v1/auth/google/status always responds with {"enabled": true|false} so the frontend can render — or skip — the “Sign in with Google” button without 404 noise.
4. Bootstrap the first admin
On a fresh database, the/bootstrap page or endpoint creates the initial OWNER:
Already initialized — bootstrap is only available on an empty database), so re-running provisioning is safe.
5. Sign in (day-to-day)
The Next.js frontend handles credentials viaPOST /api/auth/callback/credentials, sets a session cookie, and every subsequent request rides on RequireAuth. No client-side state, no JWT inspection — the session is server-owned.
6. Mint a CLI token (for scripts / CI)
While session-authed in the web UI:token in CI secrets and pass it as Authorization: Bearer <token> on subsequent requests. Revoke with DELETE /api/v1/auth/cli-tokens/{id} from the same UI or via the API.
Recovery flow walkthrough
If a user forgets their password and the mailer is configured:mailer.Disabled, step 2 never happens — the user must go through host-level Admin CLI recovery instead.
Examples
Turning on Google SSO for an org
The product manager wants the team to sign in with their@example.com Google accounts instead of Crewship-local passwords.
/api/v1/auth/google/status, sees {"enabled": true}, and renders the “Sign in with Google” button. The first user to click it lands at /api/v1/auth/google/redirect → Google consent screen → /api/v1/auth/google/callback, which creates a Crewship user row tied to the Google identity and signs them in with a regular session cookie.
If Google access ever needs to be turned off later, just unset either env var and restart — the routes unmount and the frontend re-reads enabled: false on the next status poll.
CI bootstrap with no mailer configured
A Docker-based deployment pipeline must produce a logged-in-able admin account, butRESEND_API_KEY isn’t available in the build environment.
mailer.Disabled fallback keeps /forgot non-enumerable, and the admin CLI gives the operator an out-of-band recovery channel that doesn’t depend on Resend.
Pairing the CLI from a new machine
A developer just installedcrewship on a fresh laptop and wants to authenticate it against their team’s server.
API reference
Every endpoint below lives ininternal/api/router_auth.go. There’s no dedicated /api-reference/auth page — the surface is small enough to inline, and the file is the source of truth.
Bootstrap & signup (public)
| Method | Path | Description |
|---|---|---|
POST | /api/v1/bootstrap | Create the first OWNER on a fresh database. Idempotent — 403 if any user exists. |
POST | /api/v1/auth/signup | Create a new user account. Subject to allowSignup flag at server startup; disabled by default for self-hosted installs. |
GET | /api/v1/ws-token | Mint a short-lived WebSocket auth token for the current session. Auth required. |
Sign-in & sessions (mixed)
| Method | Path | Description |
|---|---|---|
POST | /api/auth/callback/credentials | NextAuth-compatible credential sign-in. Sets the session cookie. |
POST | /api/auth/signout | Clears the session cookie and revokes the server-side row. |
GET | /api/auth/session | NextAuth-compatible “who am I” probe. Returns user + expiry, or null. |
GET | /api/auth/csrf | NextAuth CSRF token. Required by NextAuth client before credential POST. |
GET | /api/auth/providers | Lists available providers (credentials + google if enabled). |
POST | /api/auth/token/refresh | Rotates the session token. Idempotent. |
GET | /api/v1/auth/sessions | List the caller’s own active sessions. Auth required. |
POST | /api/v1/auth/sessions/{id}/revoke | Revoke one of the caller’s sessions. Auth required. |
Password recovery (public)
| Method | Path | Description |
|---|---|---|
POST | /api/v1/auth/forgot | Email a reset link. Always returns 200 (non-enumeration). |
POST | /api/v1/auth/reset | Redeem a reset token. Atomically updates the password hash + invalidates all active sessions. |
CLI tokens & device pairing (mixed)
| Method | Path | Description |
|---|---|---|
POST | /api/v1/auth/cli-token | Mint a new long-lived CLI token. Auth required. |
GET | /api/v1/auth/cli-token/validate | Verify a CLI token presented as Authorization: Bearer. Auth required. |
GET | /api/v1/auth/cli-tokens | List the caller’s CLI tokens. Auth required. |
DELETE | /api/v1/auth/cli-tokens/{tokenId} | Revoke one of the caller’s CLI tokens. Auth required. |
POST | /api/v1/auth/pair/start | Begin device-code pairing; returns code + expires_at. Auth required. |
GET | /api/v1/auth/pair/poll | Poll for redemption. Auth required. |
POST | /api/v1/auth/pair/redeem | Redeem a pairing code, return CLI token. Public by design — the code IS the credential. |
Google OAuth (conditional)
Routes mount only whenGOOGLE_CLIENT_ID AND GOOGLE_CLIENT_SECRET are both set.
| Method | Path | Description |
|---|---|---|
GET | /api/v1/auth/google/redirect | Begin OAuth flow; 302s to Google. |
GET | /api/v1/auth/google/callback | OAuth callback; creates or updates the Crewship user, sets session cookie. |
GET | /api/v1/auth/google/status | Returns {"enabled": bool}. Always mounted, even when OAuth is disabled, so the frontend can decide button rendering. |
Onboarding (auth-required, but workspace-less)
Mounted alongside auth because the wizard runs before any workspace context exists.| Method | Path | Description |
|---|---|---|
GET | /api/v1/onboarding/status | Returns wizard progress. |
POST | /api/v1/onboarding/setup | Submit a single wizard step. |
POST | /api/v1/onboarding/complete | Mark onboarding complete and provision the first workspace + crew. |
Common pitfalls
CREWSHIP_PUBLIC_URLis load-bearing for security, not just UX. If you leave it unset and configure a mailer, the handler refuses to send reset links — by design. Don’t be tempted to “fix” it by falling back tor.Host; theHostheader is attacker-tainted, and the resulting reset link would point at whatever origin the attacker put in theirPOST /forgotrequest. Set the env var to your real public origin or accept that recovery requires the Admin CLI.- Never leak whether an email is registered.
/forgotalways returns 200 — preserve that. Adding “user not found” or “we just sent you an email” responses gives an attacker an account-enumeration oracle. The browser-side UX intentionally says “If an account exists, you’ll get an email” for the same reason. - CLI tokens are not retrievable after creation. The bcrypt hash is stored; the raw token is shown exactly once at
POST /api/v1/auth/cli-tokenresponse time. If a user loses theirs, the path is “revoke + mint new” — don’t add an endpoint that re-displays the value. - Pairing codes are single-use and 10-minute-TTL’d. If a user starts pairing and walks away, the code expires on its own. Don’t extend the TTL to “be nice” — the security argument depends on a short window where a phished code can’t be used.
/api/v1/auth/resetinvalidates ALL of the user’s sessions. This is a feature: if a password reset happens because of suspected compromise, leaving old session cookies valid would defeat the rotation. Anything UI-side that depends on “logged in everywhere” must reauthenticate after a reset.- Google OAuth fails closed when half-configured. Setting only
GOOGLE_CLIENT_ID(no secret), or only the secret, leaves the/api/v1/auth/google/*routes unmounted. The frontend reads/api/v1/auth/google/statusand renders accordingly. Don’t try to mount one without the other. - Signup is OFF by default for self-hosted installs.
allowSignupis a server-startup flag. On a multi-tenant SaaS deployment you’d flip it on; for a self-hosted single-org install, leaving/api/v1/auth/signupreturning 404 is correct. - NextAuth clients require the CSRF dance. A frontend that POSTs
/api/auth/callback/credentialswithout first GETting/api/auth/csrfand sending the token gets 401. This is the NextAuth contract — not optional even when you “trust” the origin. - Bcrypt cost factor must match across signup, admin CLI, and pairing redemption. All three paths produce hashes the same verifier reads. If you change the cost factor in one place and not the others, you’ve just made some users’ passwords slower to verify than others — measurable timing oracle.
- Don’t store the session cookie in
localStorage. It’s intentionallyHttpOnly+Secure+SameSite=Lax. Anything that lifts it into JS-readable storage breaks the XSS-resistance promise of server-side sessions.
Related
- Onboarding — the first-run flow that bootstraps the initial admin account.
- Credentials — where the auth layer’s
SECRET-tier credentials (OAuth client secrets, mailer keys) live. - Installation — the
CREWSHIP_PUBLIC_URLandRESEND_API_KEYenv vars that the recovery flow depends on.