Skip to content

Identity authentication ​

The service uses Huma HTTP contracts, application use cases, repository ports, and PostgreSQL adapters. The monolith wires it through module.NewModule and injects the shared pkg/email.Sender used by identity and notifications.

Implemented endpoints under /api/v1/auth:

MethodPathSuccessPurpose
POST/register201Create an active user; return public profile
POST/login200Check credentials; create an independent session
POST/refresh200Rotate refresh token; return new token pair
POST/logout200Revoke the session identified by a refresh token
GET/me200Validate access token and session; return public profile
PUT/me200Replace name, phone, and avatar URL
POST/logout-all200Revoke all sessions, including the current session
POST/email-verification202Request a verification token by email
POST/email-verification/confirm200Consume token and verify the account email
POST/password/forgot202Request a password-reset token by email
POST/password/reset200Consume token, replace password, and revoke sessions
PUT/password200Check current password, replace it, and revoke sessions
GET/sessions200List the user's active, unexpired sessions
DELETE/sessions/{sessionId}200Revoke an owned session

All 14 operations are implemented. Users can still log in before verifying their email. Registration does not automatically send email: call /email-verification to request the message. Protected endpoints require Authorization: Bearer <access_token>. Documentation-only modules without a database return 503.

Setup ​

Set DATABASE_URL and JWT_SECRET in the root .env or process environment. Generate a random signing secret with openssl rand -hex 32; the server rejects secrets shorter than 32 bytes. JWT_EXPIRATION_HOURS accepts 1–24 (default 24), and REFRESH_TOKEN_EXPIRATION_DAYS accepts 1–365 (default 7). Configuration is loaded once at startup. Redis is not used by authentication. Email defaults to the shared console adapter and requires no provider credentials.

Apply identity migrations before starting the server, from the repository root:

sh
go run ./tools/cli database migrate --service identity
make dev-api

The CLI loads the root .env and also accepts --database-url for another instance. Its migrations are embedded, so the built ./bin/pcli does not need the repository source tree. Migration 00007 adds case-insensitive email uniqueness, refresh-token history, and shared login throttling. If existing accounts differ only by email case, resolve those duplicates before applying it. Migrations are not applied by API startup. Migration 00008 adds persistent per-email/per-purpose request limits; apply it when upgrading from the core authentication implementation.

Request flow ​

sh
curl -X POST http://localhost:8080/api/v1/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@example.com","password":"ExamplePassword123!","name":"Jane Doe"}'

curl -X POST http://localhost:8080/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@example.com","password":"ExamplePassword123!"}'

Login returns the success envelope with data.access_token, data.refresh_token, data.token_type: "Bearer", and data.expires_in (seconds). Send Authorization: Bearer <access_token> to GET /me. Send {"refreshToken":"<refreshToken>"} to /refresh or /logout.

Store the newly returned refresh token after every refresh. Serialize refreshes in the client: concurrent uses of the same token count as replay, and revoke that session. Refresh does not extend the session's original expiration. Logout accepts current or previously consumed tokens, is idempotent for unknown/revoked tokens, and leaves other sessions active. Access-token validation queries the session, so logout and replay revocation invalidate access immediately.

Successful responses contain message, status_code, and data. Session lists return an array in data; acknowledgments return data: {}. See the route response audit for every implemented route.

All auth responses use Cache-Control: no-store. Duplicate emails return 409; wrong passwords and unknown accounts share a 401 response; inactive/suspended accounts with correct passwords return 403. Login allows ten attempts per normalized email per fixed 15-minute window, across server instances, then returns 429. Other unexpected persistence failures return a generic 500.

Error logs and request traces ​

Every API request receives a trace_id (the Chi request_id when available). Authentication errors are logged before Huma replaces internal details with a safe client response. A 5xx record includes the original wrapped error, error_type, failed_operation, trace_path, source location, and the stack captured at the first failing traced operation. PostgreSQL errors also include safe structured fields such as SQLSTATE, table, column, and constraint.

Register and login trace the HTTP handler, use case, authentication service, password/token operation, and repository calls. Successful span events are debug-level; the complete trace path is always attached to the final error log. Passwords, access/refresh tokens, password hashes, authorization headers, and request bodies are never added to these records.

Verification and recovery emails ​

Both request endpoints accept {"email":"user@example.com"}. The console adapter writes one JSON record per email to the API terminal, with adapter, to, subject, and body fields. The body contains the token, expiry, endpoint, and JSON payload to submit; there are no frontend links or external deliveries.

sh
curl -X POST http://localhost:8080/api/v1/auth/email-verification \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@example.com"}'

# Copy the token from the console email.
curl -X POST http://localhost:8080/api/v1/auth/email-verification/confirm \
  -H 'Content-Type: application/json' \
  -d '{"token":"<verification-token>"}'

curl -X POST http://localhost:8080/api/v1/auth/password/forgot \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@example.com"}'

curl -X POST http://localhost:8080/api/v1/auth/password/reset \
  -H 'Content-Type: application/json' \
  -d '{"token":"<reset-token>","newPassword":"ReplacementPassword123!"}'

Verification tokens expire after one hour; reset tokens after 30 minutes. Tokens contain 256 random bits and only their SHA-256 hashes are persisted. Their purposes are separate: a verification token cannot reset a password. Confirmation and reset are single-use, including simultaneous requests. Successful verification invalidates sibling verification tokens. Password reset/change invalidates all outstanding reset tokens and revokes all sessions in the same database transaction. A concurrent login with a password verified before a password change cannot create a session using that stale password.

Requests for unknown, suspended/inactive, or already-verified accounts (for verification) return the same 202 acknowledgement as eligible accounts. No token is returned over HTTP. Each normalized email is limited to three requests per hour for each purpose, including unknown addresses; exceeding that returns 429. Correctly authenticated password changes have a separate limit of ten attempts per user per 15 minutes.

Delivery runs synchronously through the injected sender. A failed send is logged without the message or token, and the newly issued token is removed where database cleanup succeeds. The API still returns the generic 202 response; it is not a delivery guarantee. There is no automatic retry queue. The console output contains real short-lived credentials by design and is intended for development. Replace it with a real provider before using these email flows in production.

Profile and session management ​

PUT /me accepts name and optional phone/avatarUrl. It trims input, rejects blank names, allows only absolute HTTP(S) avatar URLs without embedded credentials, and clears omitted optional fields. Email, account status, roles, and password cannot be changed through this endpoint.

GET /sessions returns only the authenticated user's active, unexpired sessions, ordered by most recent activity. It identifies the current session and includes login IP/user-agent and timestamps, never token values/hashes. last_active_at is updated at login/refresh, not on every API call. Device labels remain empty when unavailable. IP metadata follows the server's existing proxy middleware and is not used to authorize requests.

DELETE /sessions/{sessionId} returns 404 for unknown or another user's sessions. Revoking an already-revoked owned session is idempotent while the caller remains authenticated. Revoking the current session or calling /logout-all immediately invalidates its access token, so subsequent protected requests return 401.

PUT /password accepts currentPassword and newPassword. An incorrect current password returns 400; invalid/missing bearer credentials return 401. A successful change requires a fresh login on every device. Invalid, expired, or consumed verification/reset tokens return 400. Password length remains 8–128 characters.

Security and operations ​

Passwords use salted PBKDF2-HMAC-SHA256 with 600,000 iterations and constant-time comparison, following the OWASP work-factor guidance. The old unsalted SHA-256 stub hashes are intentionally not accepted. Any manually seeded accounts using that stub need credentials provisioned with the new hasher. JWT validation restricts the algorithm to HS256 and requires the expected issuer, audience, expiration, user ID, and session ID. Refresh tokens contain 256 random bits; PostgreSQL stores only their SHA-256 hashes, including consumed-token history.

Expired/revoked sessions, email-token history, and expired rate-limit rows need periodic cleanup at scale. Delete sessions only after their expiration; token history cascades with session deletion. Per-IP/edge abuse controls and automated cleanup are outside the current implementation. The other service modules retain their existing route behavior; the identity changes do not add authorization to those routes.

Verification ​

sh
go test -race ./pkg/email/... ./services/identity/... ./services/notification/... ./services/api/... ./pkg/api/...
AUTH_TEST_DATABASE_URL='postgres://prescriptoo:prescriptoo@localhost:5432/prescriptoo?sslmode=disable' \
  go test -race ./services/identity/internal/presentation/http
go run ./tools/cli generate openapi

Each integration test creates a unique schema, applies all identity migrations, exercises real HTTP handlers and PostgreSQL repositories, and drops the schema. It requires PostgreSQL 13+ and permission to create schemas. Tests skip when AUTH_TEST_DATABASE_URL is unset or when -short is passed. It covers registration, case-insensitive duplicates, login without verification, generic credential errors, rotation and replay, concurrent refreshes, logout, session expiry, suspended accounts, and login throttling. Additional tests cover all nine remaining operations, profile validation, ownership isolation, email eligibility/limits, expiry and token-purpose separation, delivery failure cleanup, concurrent token consumption/password changes, stale-password session creation, and rollback when session revocation fails. Shared-email tests cover concurrent console writes, cancellation, writer errors, and notification sender integration. Crypto tests cover salts, long Unicode passwords, invalid signatures, algorithms, and claims.

Documentation follows the source. Planned contracts are marked explicitly.