logo
Development
Search
04 · Pull Login (Login with GPTBots Workspace / M-Auth)

04 · Pull Login (Login with GPTBots Workspace / M-Auth)

For when the developer's extension app is a standalone site that wants to offer a "Login with GPTBots Workspace" button. After clicking it, the user goes to GPTBots to log in and select a workspace, and is then brought back with the login state and the logged-in user's identity information.

This uses the OAuth2 authorization code + PKCE: the browser only receives a one-time code, and the actual wsa is exchanged by your backend using the code + PKCE code_verifier. The wsa never enters the browser URL / history / Referer — making it more secure than push.

Verification after obtaining the wsa is exactly the same as push (see 05). This article only covers "how to obtain the wsa."

1. End-to-End Sequence

loading...
sequenceDiagram
    participant WS as Workspace "Extensions" page
    participant GB as GPTBots platform
    participant FE as Extension app landing page
    participant BE as Extension app backend

    Note over WS: User clicks the app icon
    WS->>GB: POST sign-token
    Note over GB: Verify the clicker is a member of this workspace<br/>Sign wsa with the secret (5-minute JWT, aud=your host)
    GB-->>WS: Return wsa
    WS->>FE: Open app_home_url?wsa=JWT
    Note over FE: consumeHandoff(), read ?wsa=
    FE->>BE: POST /session/exchange (carrying wsa)
    Note over BE: verifyWsa() → identity<br/>Establish own session
    BE-->>FE: identity
    Note over FE: history.replaceState strips ?wsa=

Platform Endpoints

Purpose Method Path
Authorization entry (browser navigation) GET /api/console/account/extension-app/authorize
Token exchange (backend → backend) POST /api/console/account/extension-app/token

/authorize Parameters

Parameter Required Description
client_id Yes The extension app entry URL (app home URL)
redirect_uri Yes The callback address; its host must be same-origin as client_id (same scheme + host)
state Yes A random CSRF string, returned verbatim on callback for verification
code_challenge Yes base64url(sha256(code_verifier)), no padding
code_challenge_method Yes Only S256 is accepted (case-sensitive)
workspace_id No A preselected workspace, skipping the organization selection page

/authorize performs a 302 based on the login state to: the GPTBots login page (not logged in) / the organization selection page (logged in but no organization selected) / redirect_uri?code&state (organization selected).

/token Request and Response

Request body (called by the backend):

{ "code": "…", "codeVerifier": "…" }
                      
                      { "code": "…", "codeVerifier": "…" }

                    
This code block in the floating window

Success response:

{ "code": 0, "msg": "OK", "data": { "wsa": "eyJhbGciOiJIUzI1NiJ9...", "token_type": "Bearer", "expires_in": 300 } }
                      
                      {
  "code": 0,
  "msg": "OK",
  "data": { "wsa": "eyJhbGciOiJIUzI1NiJ9...", "token_type": "Bearer", "expires_in": 300 }
}

                    
This code block in the floating window

SDK Integration

Frontend: Initiate Login (On Button Click)

import { startWorkspaceLogin } from '@gptbots/workspace-extension-sdk'; // when "Login with GPTBots Workspace" is clicked: await startWorkspaceLogin({ authorizeUrl: 'https://www.gptbots.ai/api/console/account/extension-app/authorize', clientId: 'https://app.example.com/land', // = the app home URL you registered redirectUri: 'https://app.example.com/callback', // the host must be same-origin as clientId // workspaceId: 'p-xxx', // optional: preselect a workspace, skipping the organization selection page // state: '...', // optional: by default, a 16-byte random CSRF state is auto-generated }); // The SDK automatically: generates PKCE (verifier→challenge), stores verifier+state in sessionStorage, // verifies that authorizeUrl is an absolute URL, requires a secure context (HTTPS/localhost), then redirects to /authorize
                      
                      import { startWorkspaceLogin } from '@gptbots/workspace-extension-sdk';

// when "Login with GPTBots Workspace" is clicked:
await startWorkspaceLogin({
  authorizeUrl: 'https://www.gptbots.ai/api/console/account/extension-app/authorize',
  clientId: 'https://app.example.com/land',      // = the app home URL you registered
  redirectUri: 'https://app.example.com/callback', // the host must be same-origin as clientId
  // workspaceId: 'p-xxx',   // optional: preselect a workspace, skipping the organization selection page
  // state: '...',           // optional: by default, a 16-byte random CSRF state is auto-generated
});
// The SDK automatically: generates PKCE (verifier→challenge), stores verifier+state in sessionStorage,
// verifies that authorizeUrl is an absolute URL, requires a secure context (HTTPS/localhost), then redirects to /authorize

                    
This code block in the floating window

Frontend: The Callback Landing Page

loading...
sequenceDiagram
    participant FE as Extension app frontend
    participant GB as GPTBots
    participant BE as Extension app backend

    Note over FE: startWorkspaceLogin()<br/>generate PKCE(verifier→challenge)<br/>store in sessionStorage, 302 redirect
    FE->>GB: GET /authorize
    alt Not logged in
        GB-->>FE: 302 to the GPTBots login page (reuses the existing login)
    else Logged in, no organization selected
        GB-->>FE: 302 to the organization selection page
    else Logged in, organization selected
        Note over GB: Issue a one-time code (Redis,<br/>bound to account/project/app/redirect/challenge)
        GB-->>FE: 302 redirect_uri?code&state
    end
    Note over FE: completeWorkspaceLogin()<br/>verify state(CSRF), retrieve verifier
    FE->>BE: POST {code, codeVerifier}
    Note over BE: exchangeWorkspaceCode()
    BE->>GB: POST /token {code, verifier}
    Note over GB: Verify code (one-time GETDEL) + PKCE<br/>sign wsa
    GB-->>BE: Return wsa
    Note over BE: verifyWsa(wsa) → establish own session
    BE-->>FE: identity

Backend: Exchange for the wsa and Verify

import { exchangeWorkspaceCode, verifyWsa } from '@gptbots/workspace-extension-verify'; // POST /session/workspace-login { code, codeVerifier } app.post('/session/workspace-login', async (req, res) => { try { const { wsa } = await exchangeWorkspaceCode({ tokenUrl: 'https://www.gptbots.ai/api/console/account/extension-app/token', code: req.body.code, codeVerifier: req.body.codeVerifier, // timeoutMs: 10000, // default 10s, prevents a slow platform response from hanging your request; pass 0 to disable }); const identity = verifyWsa(wsa, { secret: process.env.EXTENSION_APP_SECRET, audience: 'app.example.com', }); const sid = createSession(identity); res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' }); res.json(identity); } catch (e) { res.status(401).json({ error: String(e) }); } });
                      
                      import { exchangeWorkspaceCode, verifyWsa } from '@gptbots/workspace-extension-verify';

// POST /session/workspace-login  { code, codeVerifier }
app.post('/session/workspace-login', async (req, res) => {
  try {
    const { wsa } = await exchangeWorkspaceCode({
      tokenUrl: 'https://www.gptbots.ai/api/console/account/extension-app/token',
      code: req.body.code,
      codeVerifier: req.body.codeVerifier,
      // timeoutMs: 10000,   // default 10s, prevents a slow platform response from hanging your request; pass 0 to disable
    });
    const identity = verifyWsa(wsa, {
      secret: process.env.EXTENSION_APP_SECRET,
      audience: 'app.example.com',
    });
    const sid = createSession(identity);
    res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' });
    res.json(identity);
  } catch (e) {
    res.status(401).json({ error: String(e) });
  }
});

                    
This code block in the floating window

The returned wsa is the same JWT contract as push, and verifyWsa is used identically.

Security Constraints (Required Reading)

  1. redirect_uri must be same-origin as the registered app: its scheme + host must exactly equal client_id. /authorize is a browser navigation endpoint and cannot return a JSON error; when redirect_uri is missing / not http(s) / cross-origin, it never redirects to an unverified address but instead returns to the organization selection page with ?error=invalid_request. This is key to preventing open redirects / token leakage.
  2. PKCE is enforced: only code_challenge_method=S256 is accepted (case-sensitive), and code_challenge = base64url(sha256(code_verifier)) with no padding. The current version does not use a client_secret; PKCE binds the "session that initiated" to the "session that exchanges."
  3. One-time code: stored in Redis with a 10-minute TTL and atomically consumed on exchange, so it cannot be replayed. A replay/expiry reports 403209 invalid_grant, and a code_verifier mismatch reports 403210 invalid_verifier.
  4. state (CSRF): the SDK stores state and code_verifier in sessionStorage, and only continues on callback if state matches.
  5. Organization scope: at authorization time, the platform verifies that the account is a member of the selected workspace and that the app is available in that organization (an organization's private extension is visible only to its owning organization); once an organization admin has disabled an app, no token will be issued for that organization even if it is still in the platform dictionary.

/token Exchange-Phase Error Codes

Structural errors on /authorize (client_id/redirect_uri missing or cross-origin, an invalid code_challenge, or a code_challenge_method other than S256) do not return JSON; instead, they 302 back to the organization selection page with ?error=invalid_request. The table below lists only the JSON error codes for the /token phase.

code Meaning Trigger condition
403209 Invalid grant The code is missing, expired, or already used (replay)
403210 Invalid verifier The PKCE code_verifier does not match the code_challenge

The ?error= values on the callback URL: invalid_request (structural error) / access_denied (not a member or the app is unavailable in that organization) / server_error (an unexpected failure). The SDK's completeWorkspaceLogin throws these as WorkspaceLoginError('AuthorizeError').

Next step: for verification and security in either push or pull, read 05 - Token Verification and Security; for the complete API, see 06 - SDK API Reference.