eContract Developers

OAuth 2.1

Let people connect your app or AI client to their eContract workspace with OAuth 2.1, PKCE, discovery and dynamic client registration.

Use OAuth when your software acts for other people: an AI client (Claude, ChatGPT, Cursor, VS Code), a SaaS integration, a desktop tool. Each user signs in to eContract, picks a workspace, approves the scopes, and your app gets tokens bound to that user, workspace and app. For your own backend an API key is simpler.

eContract implements the authorization code flow of OAuth 2.1 with PKCE, as the MCP authorization spec expects: RFC 8414 metadata, RFC 9728 protected resource metadata, RFC 8707 resource indicators, Client ID Metadata Documents, RFC 7591 dynamic registration, RFC 7009 revocation and RFC 9207 iss in authorization responses.

Discovery

DocumentURL
Authorization server metadata (RFC 8414)https://api.econtract.online/.well-known/oauth-authorization-server
Same document at the OpenID pathhttps://api.econtract.online/.well-known/openid-configuration
Protected resource metadata of the MCP server (RFC 9728)https://api.econtract.online/.well-known/oauth-protected-resource/mcp (also at /.well-known/oauth-protected-resource)

The authorization server metadata:

{
  "issuer": "https://api.econtract.online",
  "authorization_endpoint": "https://api.econtract.online/oauth/authorize",
  "token_endpoint": "https://api.econtract.online/oauth/token",
  "registration_endpoint": "https://api.econtract.online/oauth/register",
  "revocation_endpoint": "https://api.econtract.online/oauth/revoke",
  "scopes_supported": ["contracts:read", "contracts:write", "…", "ai:use", "offline_access"],
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
  "revocation_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
  "client_id_metadata_document_supported": true,
  "authorization_response_iss_parameter_supported": true,
  "subject_types_supported": ["public"],
  "service_documentation": "https://developers.econtract.online"
}

eContract issues no ID tokens (there is no jwks_uri); tokens are opaque strings.

Register your client

Pick one of two ways. Either way the client is public by default (no secret; PKCE protects the code).

Host a JSON document at an https URL you control and use that URL as your client_id. eContract fetches it on first use. Nothing to register, and the consent screen shows your domain as verified.

{
  "client_id": "https://app.example.com/oauth/client-metadata.json",
  "client_name": "Example Contracts Assistant",
  "client_uri": "https://app.example.com",
  "logo_uri": "https://app.example.com/logo.png",
  "redirect_uris": ["https://app.example.com/oauth/callback", "http://127.0.0.1/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "contracts:read contracts:write templates:read offline_access"
}

Rules:

  • The URL must be https with a path, at most 512 characters, no fragment, no credentials, no ./.. segments.
  • The document's client_id must equal its URL; client_name and redirect_uris are required.
  • Only public clients: token_endpoint_auth_method must be none (or absent) and the document must not contain a client_secret.
  • eContract fetches it over https from a public address only, without redirects, within 5 seconds and up to 64 KB, and caches it as your Cache-Control / Expires headers allow (1 hour by default, at most 24 hours; no-store re-fetches every time).

Dynamic client registration (RFC 7591)

For clients that cannot host a document:

curl -X POST https://api.econtract.online/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Example Contracts Assistant",
    "redirect_uris": ["http://127.0.0.1:33418/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "token_endpoint_auth_method": "none"
  }'
{
  "client_id": "eco_client_3f9a…",
  "client_id_issued_at": 1791622800,
  "client_name": "Example Contracts Assistant",
  "redirect_uris": ["http://127.0.0.1:33418/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
  • token_endpoint_auth_method: none (default), client_secret_post or client_secret_basic. Confidential clients also get client_secret (eco_cs_…, shown once, client_secret_expires_at: 0).
  • grant_types defaults to ["authorization_code"]. Add refresh_token to always receive refresh tokens (see Tokens).
  • Optional metadata that is echoed back: client_uri, logo_uri (https only), scope, application_type, software_id, software_version, tos_uri, policy_uri, contacts.
  • At most 20 redirect URIs; 300 registrations per hour per IP address.
  • Dynamically registered clients are shown as unverified on the consent screen.

Redirect URI rules

Redirect URIAccepted
https://… on any hostYes
http://localhost, http://127.0.0.1, http://[::1], any portYes (loopback, for native apps and CLIs)
http:// on any other hostNo: the registration is refused
Custom schemes such as cursor://… or vscode://…Dropped: the rest of the registration is kept and the accepted list is echoed back; refused only if no URI is left
With a #fragment or user:password@No

At /oauth/authorize the redirect_uri must match a registered URI exactly, except loopback URIs, where the port is ignored (register http://127.0.0.1/callback, then use any free port at run time). A request whose client is unknown or whose redirect URI does not match is never redirected; the user sees an error page instead.

Authorization code flow with PKCE

1. Send the user to the authorization endpoint

Create a random code_verifier (43–128 characters of A-Z a-z 0-9 - . _ ~) and its S256 code_challenge, plus a random state, then open:

https://api.econtract.online/oauth/authorize
  ?response_type=code
  &client_id=https%3A%2F%2Fapp.example.com%2Foauth%2Fclient-metadata.json
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &state=af0ifjsldkj
  &scope=contracts%3Aread%20contracts%3Awrite%20offline_access
  &resource=https%3A%2F%2Fapi.econtract.online
Parameter
response_typecode (the only type)
client_idYour metadata document URL or eco_client_…
redirect_uriOptional only when the client has exactly one non-loopback redirect URI
code_challenge, code_challenge_methodRequired, S256 only
stateRecommended, at most 2048 characters
scopeSpace-separated. Unknown names are ignored; without any known scope you get the default set contracts:read contracts:write templates:read files:read files:write ai:use
resourceWhich API the token is for, see Audience. May be repeated
response_modeOnly query

2. The user signs in and consents

eContract shows its consent page at https://econtract.online/oauth/consent (after sign-in if needed) with your app's name and logo, the redirect host, a verified-domain badge for metadata-document clients or an "unverified" note for registered ones, a warning when every redirect URI is on localhost, and the scopes in plain language. The user:

  • picks the workspace the app may use (they must be an active member),
  • may untick scopes (they can only remove, never add),
  • approves or denies.

The pending request is valid for 10 minutes.

3. Receive the code

eContract redirects to your redirect_uri:

https://app.example.com/oauth/callback?code=eco_ac_9c1d…&state=af0ifjsldkj&iss=https%3A%2F%2Fapi.econtract.online

Check that state is yours and that iss equals https://api.econtract.online (RFC 9207; every authorization response, success or error, carries it). A denial comes back as error=access_denied. The code is valid for 5 minutes and works once.

4. Exchange the code for tokens

POST /oauth/token, form-encoded (JSON is accepted too):

curl -X POST https://api.econtract.online/oauth/token \
  -d grant_type=authorization_code \
  -d code=eco_ac_9c1d… \
  -d redirect_uri=https://app.example.com/oauth/callback \
  -d client_id=https://app.example.com/oauth/client-metadata.json \
  -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk \
  -d resource=https://api.econtract.online
{
  "access_token": "eco_at_5e0b…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "eco_rt_a7c4…",
  "scope": "contracts:read contracts:write offline_access"
}

Then call the API with Authorization: Bearer eco_at_…. GET /api/v1/auth/me shows the user the token acts for.

import crypto from "node:crypto";

const ISSUER = "https://api.econtract.online";
const CLIENT_ID = "https://app.example.com/oauth/client-metadata.json";
const REDIRECT_URI = "https://app.example.com/oauth/callback";

export function startLogin() {
  const verifier = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
  const state = crypto.randomBytes(16).toString("base64url");
  const url = new URL(`${ISSUER}/oauth/authorize`);
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    code_challenge: challenge,
    code_challenge_method: "S256",
    state,
    scope: "contracts:read contracts:write offline_access",
    resource: ISSUER,
  }).toString();
  return { url: url.toString(), verifier, state }; // keep verifier + state in the session
}

export async function finishLogin(callbackUrl, { verifier, state }) {
  const params = new URL(callbackUrl).searchParams;
  if (params.get("state") !== state) throw new Error("state mismatch");
  if (params.get("iss") !== ISSUER) throw new Error("issuer mismatch");
  if (params.get("error")) throw new Error(params.get("error"));

  const res = await fetch(`${ISSUER}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: params.get("code"),
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: verifier,
      resource: ISSUER,
    }),
  });
  if (!res.ok) throw new Error(JSON.stringify(await res.json()));
  return res.json(); // { access_token, refresh_token?, expires_in, scope }
}
import base64
import hashlib
import secrets
from urllib.parse import urlencode, urlparse, parse_qs

import requests

ISSUER = "https://api.econtract.online"
CLIENT_ID = "https://app.example.com/oauth/client-metadata.json"
REDIRECT_URI = "https://app.example.com/oauth/callback"


def start_login():
    verifier = secrets.token_urlsafe(32)
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
    state = secrets.token_urlsafe(16)
    url = f"{ISSUER}/oauth/authorize?" + urlencode({
        "response_type": "code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
        "state": state,
        "scope": "contracts:read contracts:write offline_access",
        "resource": ISSUER,
    })
    return url, verifier, state  # keep verifier + state in the session


def finish_login(callback_url, verifier, state):
    params = {k: v[0] for k, v in parse_qs(urlparse(callback_url).query).items()}
    if params.get("state") != state or params.get("iss") != ISSUER:
        raise RuntimeError("state or issuer mismatch")
    if "error" in params:
        raise RuntimeError(params["error"])
    res = requests.post(f"{ISSUER}/oauth/token", data={
        "grant_type": "authorization_code",
        "code": params["code"],
        "redirect_uri": REDIRECT_URI,
        "client_id": CLIENT_ID,
        "code_verifier": verifier,
        "resource": ISSUER,
    })
    res.raise_for_status()
    return res.json()  # access_token, refresh_token (optional), expires_in, scope

Audience (resource)

A token works only for the resources it was issued for (RFC 8707):

resourceToken accepted by
https://api.econtract.onlineThe REST API (through the gateway)
https://api.econtract.online/mcpThe MCP server
both (repeat the parameter) or noneBoth

Any other value fails with invalid_target. Values are compared after normalisation (lower-case scheme and host, no default port, no trailing slash). At the token endpoint resource may narrow the audience to one of the resources that were authorized. A token presented to a resource it is not for gets 401 INVALID_TOKEN.

Tokens

TokenFormatLifetime
Authorization codeeco_ac_ + 64 hex5 minutes, single use
Access tokeneco_at_ + 64 hex1 hour (expires_in: 3600)
Refresh tokeneco_rt_ + 64 hex30 days from issue, renewed on every refresh

eContract stores only SHA-256 hashes of codes, tokens and client secrets.

Refresh tokens

You get a refresh token only if the user granted offline_access or your client registered the refresh_token grant type. Dynamically registered clients default to authorization_code only, so ask for one of the two.

curl -X POST https://api.econtract.online/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=eco_rt_a7c4… \
  -d client_id=https://app.example.com/oauth/client-metadata.json
  • Rotation: every refresh returns a new refresh token and retires the old one. Store the new one right away.
  • Reuse detection: presenting a retired refresh token again revokes the whole grant (every access and refresh token of that user, workspace and app). The user has to connect again. Presenting an authorization code twice does the same.
  • Optional scope narrows the new access token (it must be a subset; otherwise invalid_scope); the new refresh token keeps the full original scope. Optional resource narrows the audience.
  • A refresh fails with invalid_grant once the grant was revoked or the user is no longer an active member of the workspace.

Grants

One grant exists per app, user and workspace. Approving the same app again adds the newly approved scopes to the existing grant. After a revocation, the next approval starts over with only the scopes approved then (old tokens stay revoked). Tokens act with the user's current role, capped at contract_manager (see Machine role).

Revocation

POST /oauth/revoke (RFC 7009) with token and, for public clients, client_id:

curl -X POST https://api.econtract.online/oauth/revoke \
  -d token=eco_rt_a7c4… \
  -d client_id=https://app.example.com/oauth/client-metadata.json
  • Always answers 200 {}, also for unknown tokens. Confidential clients must authenticate; wrong credentials answer 401 invalid_client.
  • Revoking a refresh token revokes it and every token issued from the same authorization (the whole token family). Revoking an access token revokes only that token.
  • The gateway and the MCP server cache token checks for up to 30 seconds.

Connected apps

Every workspace member sees the apps they connected under Dashboard → Connected apps (/dashboard/settings/connected-apps): the app, the workspace, the granted scopes and when it was last used. Disconnect revokes the grant and all of its tokens at once.

This page, the consent screen and their API (/api/v1/auth/oauth/*) need a signed-in user; OAuth tokens and API keys cannot approve, list or revoke grants.

Scopes

The same catalogue as API keys, see Scopes, plus offline_access for refresh tokens. Ask only for what you need: users see every scope on the consent screen and may remove some, so check the scope field of the token response.

Errors

The OAuth endpoints answer RFC 6749 errors with Cache-Control: no-store:

{ "error": "invalid_grant", "error_description": "Authorization code has expired" }
ErrorWhereMeaning
invalid_requestallMissing, repeated or malformed parameter
invalid_client (401)token, revokeUnknown client or wrong secret (WWW-Authenticate: Basic when Basic auth was used)
invalid_granttokenCode or refresh token invalid, expired, used or revoked; redirect URI or PKCE verifier does not match; user left the workspace
unsupported_grant_typetokenOnly authorization_code and refresh_token
invalid_scopetokenRefresh scope is not a subset of the grant
invalid_targetauthorize, tokenresource is not one of the two resources, or was not authorized
invalid_redirect_uriregisterNo usable redirect URI
invalid_client_metadataregisterInvalid registration field
unsupported_response_type, unauthorized_clientauthorize (redirect)Only response_type=code; the client is not registered for it
access_deniedauthorize (redirect)The user denied the request
rate_limited (429)register, tokenToo many requests, see Retry-After

Errors at /oauth/authorize are sent to your redirect URI with error, error_description, state and iss, except when the client or redirect URI cannot be trusted (then the user sees an error page).

Rate limits

EndpointLimit
POST /oauth/register300 per hour per IP
POST /oauth/token600 per minute per client and IP
Client ID Metadata Document fetches60 per minute per client host
GET /oauth/authorize100 per minute per IP
All /oauth/* requests at the gateway3,000 per minute and 60,000 per hour per IP

API calls made with the tokens count against the per-grant API limits.

On this page