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
| Document | URL |
|---|---|
| Authorization server metadata (RFC 8414) | https://api.econtract.online/.well-known/oauth-authorization-server |
| Same document at the OpenID path | https://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).
Client ID Metadata Document (recommended)
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_idmust equal its URL;client_nameandredirect_urisare required. - Only public clients:
token_endpoint_auth_methodmust benone(or absent) and the document must not contain aclient_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/Expiresheaders allow (1 hour by default, at most 24 hours;no-storere-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_postorclient_secret_basic. Confidential clients also getclient_secret(eco_cs_…, shown once,client_secret_expires_at: 0).grant_typesdefaults to["authorization_code"]. Addrefresh_tokento 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 URI | Accepted |
|---|---|
https://… on any host | Yes |
http://localhost, http://127.0.0.1, http://[::1], any port | Yes (loopback, for native apps and CLIs) |
http:// on any other host | No: 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_type | code (the only type) |
client_id | Your metadata document URL or eco_client_… |
redirect_uri | Optional only when the client has exactly one non-loopback redirect URI |
code_challenge, code_challenge_method | Required, S256 only |
state | Recommended, at most 2048 characters |
scope | Space-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 |
resource | Which API the token is for, see Audience. May be repeated |
response_mode | Only 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.onlineCheck 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, scopeAudience (resource)
A token works only for the resources it was issued for (RFC 8707):
resource | Token accepted by |
|---|---|
https://api.econtract.online | The REST API (through the gateway) |
https://api.econtract.online/mcp | The MCP server |
| both (repeat the parameter) or none | Both |
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
| Token | Format | Lifetime |
|---|---|---|
| Authorization code | eco_ac_ + 64 hex | 5 minutes, single use |
| Access token | eco_at_ + 64 hex | 1 hour (expires_in: 3600) |
| Refresh token | eco_rt_ + 64 hex | 30 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
scopenarrows the new access token (it must be a subset; otherwiseinvalid_scope); the new refresh token keeps the full original scope. Optionalresourcenarrows the audience. - A refresh fails with
invalid_grantonce 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 answer401 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" }| Error | Where | Meaning |
|---|---|---|
invalid_request | all | Missing, repeated or malformed parameter |
invalid_client (401) | token, revoke | Unknown client or wrong secret (WWW-Authenticate: Basic when Basic auth was used) |
invalid_grant | token | Code or refresh token invalid, expired, used or revoked; redirect URI or PKCE verifier does not match; user left the workspace |
unsupported_grant_type | token | Only authorization_code and refresh_token |
invalid_scope | token | Refresh scope is not a subset of the grant |
invalid_target | authorize, token | resource is not one of the two resources, or was not authorized |
invalid_redirect_uri | register | No usable redirect URI |
invalid_client_metadata | register | Invalid registration field |
unsupported_response_type, unauthorized_client | authorize (redirect) | Only response_type=code; the client is not registered for it |
access_denied | authorize (redirect) | The user denied the request |
rate_limited (429) | register, token | Too 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
| Endpoint | Limit |
|---|---|
POST /oauth/register | 300 per hour per IP |
POST /oauth/token | 600 per minute per client and IP |
| Client ID Metadata Document fetches | 60 per minute per client host |
GET /oauth/authorize | 100 per minute per IP |
All /oauth/* requests at the gateway | 3,000 per minute and 60,000 per hour per IP |
API calls made with the tokens count against the per-grant API limits.