Skip to main content
AVnesterDevelopers

OAuth 2.1 walkthrough

OAuth + DCR + DPoP in three minutes

Every tool also answers without signing in, with none of this. OAuth 2.1 is the path to limits counted per user + request attribution — and it’s self-serve via Dynamic Client Registration (RFC 7591).

This page is for apps where a person signs in, with their AVnester mobile number, and authorises. A backend calling on its own, with no one signed in, uses a server credential instead (an API key, or OAuth client credentials once they are enabled).

Step 1

Register a client via DCR (RFC 7591)

POST your client metadata to the registration endpoint. No human approval, no email thread. You get a client_id instantly. Use token_endpoint_auth_method: "none" — end-user OAuth 2.1 with PKCE doesn’t need a client secret, because the person who signs in is the proof. A server calling with no one signed in is a different job: it uses a server credential, created in the developer console rather than registered here.

# register the client and get back a client_id
curl -X POST https://auth.avnester.com/oauth/register \
  -H 'Content-Type: application/json' \
  -d '{
       "client_name": "My LLM Agent",
       "redirect_uris": ["https://my-app.example.com/callback"],
       "grant_types": ["authorization_code", "refresh_token"],
       "response_types": ["code"],
       "token_endpoint_auth_method": "none"
     }'
Response
{
  "client_id": "av_dcr_8f3d2c19a4b6e7d5",
  "client_id_issued_at": 1716700800,
  "registration_access_token": "...",
  "registration_client_uri": "https://auth.avnester.com/oauth/register/av_dcr_8f3d2c19a4b6e7d5",
  "grant_types": ["authorization_code", "refresh_token"],
  "redirect_uris": ["https://my-app.example.com/callback"],
  "token_endpoint_auth_method": "none"
}

Step 2

Run the authorization-code-with-PKCE flow

  1. Generate a PKCE code_verifier (~64 random URL-safe chars) and the matching code_challenge = SHA-256(verifier).
  2. Redirect the user to https://auth.avnester.com/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256&scope=calculators:use%20listings:read:public%20locality:read:public
  3. The person signs in with their AVnester mobile number: a one-time code on avnester.com, and a new number gets a free account. They then approve your app on the consent screen.
  4. After consent, the user is redirected to your redirect_uri with ?code=....
  5. Exchange that code at the token endpoint for an access token + refresh token. DPoP is an optional uplift — see Step 3 to sender-constrain your token; plain bearer tokens work too.

Store the refresh token each refresh returns and discard the old one. A public client that is not DPoP-bound gets a new refresh token on every refresh, except one whose registered redirect URIs are all loopback (localhost, 127.0.0.1 or [::1]), which keeps a stable one that expires 30 days after sign-in (only a rotating token runs on, up to a year in all). Reusing a replaced refresh token fails and revokes the grant, so the person has to sign in again.

Discovery metadata (issuer, endpoints, supported scopes, JWKS URI) is at https://auth.avnester.com/.well-known/oauth-authorization-server (RFC 8414).

Step 3

Generate DPoP proofs and call tools

DPoP (RFC 9449) is optional but recommended for production. It binds the access token to a keypair you generate: every request carries a fresh DPoP proof JWT signed with that key — a stolen access token can’t be replayed from a different machine. Reuse the same keypair across requests; rotate annually.

Node.js (jose)

import { generateKeyPair, exportJWK, SignJWT } from 'jose';
import crypto from 'node:crypto';

// 1. Generate a DPoP keypair once per client (persist across requests).
const { privateKey, publicKey } = await generateKeyPair('ES256');
const publicJwk = await exportJWK(publicKey);

// 2. Build a DPoP proof JWT for each request.
async function dpopProof(method, url, accessToken) {
  const headers = {
    alg: 'ES256',
    typ: 'dpop+jwt',
    jwk: publicJwk,
  };
  const payload = {
    htm: method,
    htu: url,
    iat: Math.floor(Date.now() / 1000),
    jti: crypto.randomUUID(),
  };
  if (accessToken) {
    payload.ath = crypto
      .createHash('sha256')
      .update(accessToken)
      .digest('base64url');
  }
  return await new SignJWT(payload)
    .setProtectedHeader(headers)
    .sign(privateKey);
}

// 3. Token exchange — DPoP header on the token endpoint too.
const url = 'https://auth.avnester.com/oauth/token';
const proof = await dpopProof('POST', url);
const res = await fetch(url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    'DPoP': proof,
  },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: AUTH_CODE,
    code_verifier: PKCE_VERIFIER,
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
  }),
});
const tokens = await res.json(); // { access_token, refresh_token, ... }

// 4. Call a tool — fresh DPoP proof per request, bound to the access token.
const toolUrl = 'https://api.avnester.com/public/v1/search_properties';
const toolProof = await dpopProof('POST', toolUrl, tokens.access_token);
await fetch(toolUrl, {
  method: 'POST',
  headers: {
    'Authorization': `DPoP ${tokens.access_token}`,
    'DPoP': toolProof,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ city: 'Coimbatore' }),
});

Python (Authlib + cryptography)

from authlib.jose import jwt
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import hashlib, base64, time, uuid, requests

# 1. Generate DPoP keypair once.
private_key = ec.generate_private_key(ec.SECP256R1())
public_jwk = {  # serialize public key as JWK
    "kty": "EC", "crv": "P-256",
    "x": base64.urlsafe_b64encode(
        private_key.public_key().public_numbers().x.to_bytes(32, "big")
    ).rstrip(b"=").decode(),
    "y": base64.urlsafe_b64encode(
        private_key.public_key().public_numbers().y.to_bytes(32, "big")
    ).rstrip(b"=").decode(),
}

# 2. Build a DPoP proof per request.
def dpop_proof(method, url, access_token=None):
    payload = {
        "htm": method, "htu": url,
        "iat": int(time.time()), "jti": str(uuid.uuid4()),
    }
    if access_token:
        ath = hashlib.sha256(access_token.encode()).digest()
        payload["ath"] = base64.urlsafe_b64encode(ath).rstrip(b"=").decode()
    header = {"alg": "ES256", "typ": "dpop+jwt", "jwk": public_jwk}
    return jwt.encode(header, payload, private_key).decode()

# 3. Token exchange.
token_url = "https://auth.avnester.com/oauth/token"
r = requests.post(
    token_url,
    headers={"DPoP": dpop_proof("POST", token_url)},
    data={
        "grant_type": "authorization_code",
        "code": AUTH_CODE, "code_verifier": PKCE_VERIFIER,
        "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI,
    },
)
tokens = r.json()

# 4. Call a tool.
tool_url = "https://api.avnester.com/public/v1/search_properties"
requests.post(
    tool_url,
    headers={
        "Authorization": f"DPoP {tokens['access_token']}",
        "DPoP": dpop_proof("POST", tool_url, tokens["access_token"]),
    },
    json={"city": "Coimbatore"},
)

Go (sketch)

// Sketch only — see github.com/lestrrat-go/jwx for production-grade DPoP.
package main

import (
    "crypto/ecdsa"
    "crypto/elliptic"
    "crypto/rand"
    "encoding/json"
    "net/http"
    "strings"
)

// 1. Generate ES256 keypair once.
privKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)

// 2. Build dpop_proof(method, url, accessToken) returning a JWT signed with privKey.
//    Use github.com/lestrrat-go/jwx/jwt + jws.

// 3. Token exchange.
form := strings.NewReader(`grant_type=authorization_code&code=...&code_verifier=...&client_id=...`)
req, _ := http.NewRequest("POST", "https://auth.avnester.com/oauth/token", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("DPoP", dpopProof("POST", req.URL.String(), ""))
resp, _ := http.DefaultClient.Do(req)
var tokens map[string]any
json.NewDecoder(resp.Body).Decode(&tokens)

// 4. Tool call.
toolReq, _ := http.NewRequest("POST", "https://api.avnester.com/public/v1/search_properties",
    strings.NewReader(`{"city":"Coimbatore"}`))
toolReq.Header.Set("Content-Type", "application/json")
toolReq.Header.Set("Authorization", "DPoP "+tokens["access_token"].(string))
toolReq.Header.Set("DPoP", dpopProof("POST", toolReq.URL.String(), tokens["access_token"].(string)))
http.DefaultClient.Do(toolReq)

What OAuth gets you

The Developer tier (and what's coming)

  • 30 requests/min per tool, counted per user — the same ceiling on every tool, with no limit across tools or on uncached queries; the per-IP limits on a call made without signing in do not apply.
  • 1,000 calls a month, 100 a day — free. Usage headers on every response tell you where you are, so you can pace against the cap rather than discover it.
  • Request attribution — your client_id appears in our abuse logs so we can reach out before rate-limiting you for spikes.
  • Usage analytics — per-app requests, success rate, p95 and rate-limit share in the developer console (window by tier).
  • Business, Scale and Enterprise are contact-sales — the console shows your allowance and links you to sales. Your OAuth client is what gets upgraded: no code changes; the next access token after upgrade carries tier: "business" automatically. A licensed app can also hold server credentials for calls your backend makes on its own.

Further reading