PortModels
Log in

Authorization code flow

The standard server-side flow for confidential clients, from consent screen to access token.

For confidential clients — apps with a backend that can hold a client_secret.

html
<a href="https://app.portmodels.com/connect/authorize?client_id=acme%2Fchatbot&redirect_uri=https%3A%2F%2Fyourapp.com%2Fcallback&scope=models.run%20kv.read%20kv.write&state=RANDOM_STATE">
  Connect with PortModels
</a>
ParameterRequiredNotes
client_idyesYour app id, slash URL-encoded as %2F
redirect_uriyesMust exactly match a registered URI
scopeyesSpace-separated
staterecommendedYour CSRF token — random per attempt, verified on return

The user signs in if needed, sees a consent screen listing exactly what you asked for, and on approval is redirected to:

text
https://yourapp.com/callback?code=pmac_...&state=RANDOM_STATE

On deny you get ?error=access_denied&state=... instead.

Important

Always verify state matches the value you generated for this attempt before you exchange the code. Skipping it leaves you open to CSRF on the callback.

2. Exchange the code for a token#

The code is single-use and expires in 10 minutes. Exchange it from your backend — never expose your client_secret to a browser.

bash
curl -X POST https://api.portmodels.com/connect/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "client_id": "acme/chatbot",
    "client_secret": "pmcs_...",
    "code": "pmac_...",
    "redirect_uri": "https://yourapp.com/callback"
  }'
json
{
  "access_token": "pmc_...",
  "token_type": "bearer",
  "scope": "models.run kv.read kv.write",
  "connection_id": "..."
}

Note that client_id in the JSON body is not URL-encoded — the %2F encoding applies only to the query string of the authorize link.

3. Store the token for this login#

Store access_token securely for the corresponding app user, session, or device installation. Every successful code exchange creates an independent token, so connecting on device B leaves a token already held by device A working. Each token keeps the scopes approved in its own grant.

Connect tokens currently have no expiry timer. A token stops working when the user disconnects the app, the account becomes unavailable, or a future scope policy gives that token an expiry. Disconnecting from Settings → Connected apps revokes every token for that app and user.

If your app intentionally keeps one shared server-side session per user, it may replace its own stored token when a new login completes. That is your storage policy; PortModels does not invalidate the older token. Treat a 401 as "reconnect required" for that token rather than as a transient failure. See Revocation and token lifetime.

Complete example#

js
// 1. Start the flow
app.get('/connect', (req, res) => {
  const state = crypto.randomBytes(16).toString('hex');
  req.session.pmState = state;

  const url = new URL('https://app.portmodels.com/connect/authorize');
  url.searchParams.set('client_id', 'acme/chatbot');
  url.searchParams.set('redirect_uri', 'https://yourapp.com/callback');
  url.searchParams.set('scope', 'models.run kv.read kv.write');
  url.searchParams.set('state', state);
  res.redirect(url.toString());
});

// 2. Handle the callback
app.get('/callback', async (req, res) => {
  if (req.query.error) return res.status(400).send('Connection declined');
  if (req.query.state !== req.session.pmState) return res.status(400).send('Bad state');

  const response = await fetch('https://api.portmodels.com/connect/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      client_id: 'acme/chatbot',
      client_secret: process.env.PM_CLIENT_SECRET,
      code: req.query.code,
      redirect_uri: 'https://yourapp.com/callback',
    }),
  });

  const token = await response.json();
  await saveConnection({
    userId: req.session.userId,
    appId: 'acme/chatbot',
    accessToken: token.access_token,
    scopes: token.scope,
  });
  res.redirect('/');
});