Skip to content

Latest commit

 

History

History
269 lines (220 loc) · 13.9 KB

authentication.md

File metadata and controls

269 lines (220 loc) · 13.9 KB

Authentication and federated identity

Marreq uses one browser-authentication engine in marreq-core. Both deployment binaries share session rotation, CSRF rotation, OAuth transaction validation, identity resolution, provisioning, account linking, and audit behavior. Bearer API tokens and MCP authentication are separate and unchanged.

Delegated applications

Federated identities answer how a person signs in. Delegated OAuth grants are a separate domain: they record which external application may act for that signed-in user. Marreq never gives an external client an upstream Google, GitHub, GitLab, or OIDC credential.

Marreq is an OAuth authorization server for the remote MCP resource. It supports Authorization Code with mandatory PKCE S256 and rotating refresh tokens. The browser authorization endpoint requires the existing Marreq session and displays the application name, signed-in account, and requested scopes before Allow/Deny. Account settings lists active applications and can revoke a grant immediately.

The access-token design is opaque: authorization codes, access tokens, and refresh tokens are random 256-bit values, while PostgreSQL stores SHA-256 hashes. Codes expire after five minutes and are single use; access tokens expire after 15 minutes; refresh tokens expire after 30 days and rotate on every use. Reuse of a rotated refresh token revokes its token family. Grant revocation deletes active access tokens and revokes all refresh tokens for the grant.

OAuth endpoints:

Method Path Purpose
GET /.well-known/oauth-authorization-server Authorization-server metadata.
GET /.well-known/oauth-protected-resource MCP protected-resource metadata.
GET /.well-known/oauth-protected-resource/mcp Path-specific MCP protected-resource metadata.
POST /oauth/register Register a public client and exact redirect URIs.
GET /oauth/authorize Validate the request and show consent.
POST /oauth/authorize Allow or deny using the authenticated browser session.
POST /oauth/token Exchange a code or rotate a refresh token.
GET /api/oauth/grants List the user's connected applications.
DELETE /api/oauth/grants/{id} Revoke an owned grant (CSRF protected).

The MCP resource indicator is MARREQ_MCP_PUBLIC_URL (defaulting to <MARREQ_PUBLIC_BASE_URL>/mcp). Redirect URIs use exact matching; HTTPS is mandatory except for loopback localhost development. Token responses are explicitly non-cacheable. Authorization responses include the authorization-server issuer (iss), which is advertised in metadata.

Dynamic registration remains available for standards-compatible MCP clients. It is rate-limited per source address and accepts at most ten unique redirect URIs, with bounded client-name and URI lengths. A registered client name is self-asserted metadata, not proof of a vendor identity; the consent page states that distinction. Deployments that need a stronger client trust policy should pre-register approved clients at their ingress/provisioning layer.

Supported scopes are projects:read, requirements:read, requirements:write, requirements:approve, verifications:read, verifications:write, traceability:read, traceability:write, baselines:read, baselines:write, use_cases:read, use_cases:write and use_cases:approve. Scope is only a delegation ceiling: the authenticated Marreq user's normal membership, role, reviewer, and project authorization checks still run on every REST request.

Identity model

Local passwords are optional. External identities are keyed only by the stable pair (issuer, subject) in user_identities; email and provider usernames are profile attributes and are never proof that two accounts are the same. Marreq does not persist provider access tokens, refresh tokens, authorization codes, or ID tokens.

An unknown external identity whose email is already present is rejected with an account-link-required conflict. The user must sign in normally and connect the provider from Account settings. Marreq also refuses to disconnect a user's last usable authentication method.

Browser flow

All providers use Authorization Code with PKCE S256. A private, HttpOnly, SameSite=Lax cookie binds a random state, OIDC nonce, PKCE verifier, provider, operation, expiry, and local return_to path to the initiating browser. The cookie expires after ten minutes and is removed before callback validation, so callbacks are single use. Production should set MARREQ_SECURE_SESSION_COOKIE=1.

The callback URL is deterministic:

<MARREQ_PUBLIC_BASE_URL>/api/auth/external/<provider>/callback

Only local paths such as /projects are accepted for return_to; absolute and scheme-relative URLs are rejected.

Self-hosted Server OIDC

MARREQ_PUBLIC_BASE_URL=https://marreq.example.com
MARREQ_OIDC_ENABLED=true
MARREQ_OIDC_DISPLAY_NAME="Company SSO"
MARREQ_OIDC_ISSUER=https://sso.example.com/realms/company
MARREQ_OIDC_CLIENT_ID=marreq
MARREQ_OIDC_CLIENT_SECRET=replace-me
MARREQ_OIDC_AUTO_REGISTER=false

Register https://marreq.example.com/api/auth/external/oidc/callback at the provider. Discovery must return exactly the configured issuer. Signature, key, issuer, audience, expiry, and nonce checks are performed by openidconnect. Issuer URLs must use HTTPS, except http://localhost for local development.

The configuration works with standards-compliant discovery providers such as Keycloak and Authentik without provider-specific code. Auto-registration is off by default; pre-link identities or explicitly enable it when public provisioning is intended.

Marreq Cloud providers

Cloud supports Google OIDC plus GitHub and GitLab OAuth2. Set the matching MARREQ_<PROVIDER>_CLIENT_ID and MARREQ_<PROVIDER>_CLIENT_SECRET pair to enable a provider. Cloud automatically provisions unknown identities, including their personal workspace. Google uses the OIDC sub; GitHub uses its immutable numeric user ID with canonical issuer https://github.com; GitLab uses its stable user ID with canonical issuer https://gitlab.com.

Public API

Method Path Purpose
GET /api/auth/providers Public enabled-method discovery; contains no credentials.
GET /api/auth/external/{provider}/start Begin a login redirect.
GET /api/auth/external/{provider}/callback Provider-specific callback.
GET /api/auth/identities List the signed-in user's connected methods.
POST /api/auth/external/{provider}/link Begin explicit linking; CSRF protected.
DELETE /api/auth/identities/{id} Disconnect an owned identity; CSRF protected.

Provider secrets and protocol endpoints are backend-only. The frontend performs no token exchange.

Project authorization invariant

Authentication identifies an actor; it never grants access to project data by itself. Every project-owned entity is authorized against the project_id read from the stored entity. A project_id supplied in a URL or request body is only a constraint and must agree with stored ownership.

The canonical flow is:

  1. SessionUser or a Bearer guard authenticates the user and records the authentication source.
  2. Delegated OAuth scopes and API-token project_scope narrow what the credential may request. They do not replace current project membership.
  3. Project guards verify current membership. The shared functions in authorization.rs apply the role capability (Permission).
  4. ID-only compatibility routes load the entity first and apply the same check to its stored project. Global list compatibility routes return only entities from projects the actor may view.
  5. Requirement and verification mutation services repeat the write check at the application boundary so a future non-HTTP caller cannot bypass it. Project configuration services do the same with ManageProjectConfiguration (ManageCustomFields for custom fields): CategoryService, ApplicabilityService, StatusService, CustomFieldService and VerificationMethodService. Create checks the target project; update and delete load the row and check its stored project, and an update cannot move a row to another project. All use authorization::require_project_permission_for_service.
  6. The only unchecked path is seeding a new project's defaults: the *_bootstrap service methods and initialize_default_statuses need a ProjectBootstrap, which only ProjectService::create can build, right after it inserts the project. Bundle import uses the normal checked methods (the importer owns the new project).

Site administrators retain the documented cross-project override. Project roles map to capabilities in permissions.rs; project configuration is limited to project administrators. Status and version-approval transitions are authorized solely by membership in the explicit project_reviewers pool (any eligible project member may be designated). The role capability ApproveVersions remains for UI/compatibility display and does not by itself authorize those transitions. When no reviewer pool exists, only a site administrator may approve.

Route providers return policy-classified routes; app::declared_route_inventory unions the core API, OAuth, root, and deployment-specific mounts so every mounted REST route is declared as public, authenticated, project read, project write, approval, project management, administrator, or internal.

Intentionally public REST endpoints are limited to API/build/deployment/health metadata, authentication bootstrap/login/provider callbacks, and cache health. Cache statistics, cleanup, mutation, recommendations, and counter reset are administrator-only.

Local credentials and CSRF

Usernames use trim().to_lowercase() as their canonical account identifier for both credential lookup and rate-limit accounting. Passwords are opaque: no trimming or other transformation occurs before password verification.

Unsafe cookie-authenticated requests require the normal CSRF checks. A Bearer header bypasses CSRF only after the presented API or delegated OAuth token has been authenticated; a malformed or invalid header does not exempt a concurrent browser session.

Session lifetime

A browser session (the __Host-session / session cookie) ends at whichever limit comes first (issue #285):

Limit Variable Default Counted from
Absolute MARREQ_SESSION_ABSOLUTE_HOURS (1–8760) 720 (30 days) sign-in (created_at)
Idle MARREQ_SESSION_IDLE_MINUTES (≥ 5, or 0 = off; at most the absolute limit) 480 (8 hours) the last activity (last_seen_at)
  • Enforcement. Every session lookup (SessionRepository::find_active_session) checks expires_at, created_at and last_seen_at against the current limits in SQL, so lowering a limit also ends existing sessions. A rejected session's row is deleted and the request gets 401; the SPA then sends the user to sign in (unsaved requirement edits survive as local drafts).
  • Activity. Any request authenticated by the session cookie counts, except requests with the header X-Marreq-Background: 1. The SPA sends it on the notification poll, so an open but unused tab does not keep a session alive. Other clients should send it for automatic requests. API tokens and OAuth bearer tokens are not sessions and are unaffected.
  • Throttled touch. last_seen_at is written at most once per minute (or a tenth of a shorter idle limit) per session, with a conditional UPDATE … WHERE last_seen_at < …, so concurrent requests do not all write. The idle limit is therefore exact to within that interval.
  • Cleanup. Every server deletes sessions past any limit once an hour.
  • Revocation is unchanged: sign-out deletes the session; changing your password, or an administrator setting it, deletes all of that user's sessions.

The cookie itself has no Max-Age: it is a browser-session cookie, and the server-side limits above decide.

Login rate limiting

POST /api/auth/login is protected by LoginRateLimiter (marreq-core/src/auth/rate_limiter/), keyed separately by canonical username and by client IP: a progressive delay from the 3rd consecutive failure (2 s × (failures − 2), at most 30 s), a 15-minute lockout after 10 failures for a username or 20 for an IP, and a reset of both on a successful login.

The policy keeps its counters in a RateLimitStore, chosen at startup with MARREQ_RATE_LIMIT_STORE (issue #286):

Value Store Use
postgres (default) PostgresRateLimitStore, table login_rate_limits Any deployment; required with more than one backend replica
memory InMemoryRateLimitStore A single process (counters are per process and lost on restart)

The PostgreSQL store:

  • Shares counters and lockouts between all replicas that use the same database, and keeps them over restarts.
  • Updates atomically: each read-modify-write runs in one transaction that locks the subject's row (SELECT … FOR UPDATE), so concurrent failures on different replicas are all counted.
  • Uses the database clock for lockout times (now()), so clock skew between replicas does not shorten or extend a lockout.
  • Expires records: a successful login deletes them, and every replica sweeps the table every 10 minutes, removing records idle for longer than MARREQ_RATE_LIMIT_RETENTION_HOURS (default 24) whose lockout, if any, has ended. (In memory, a count below the lockout threshold never decays.)
  • Fails open on database errors: the attempt is allowed and the error is logged ([marreq] login rate limit store: …). The login itself needs the same database, and failing closed would lock every user out after an outage.
  • Stores the canonical username or the IP address only while a subject has failures or a lockout; no passwords.

Startup logs the active store ([marreq] login rate limit store: postgres). Test builds always use the in-memory store.