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
wsais exactly the same as push (see 05). This article only covers "how to obtain thewsa."
1. End-to-End Sequence
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": "…" }
Success response:
{
"code": 0,
"msg": "OK",
"data": { "wsa": "eyJhbGciOiJIUzI1NiJ9...", "token_type": "Bearer", "expires_in": 300 }
}
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
Frontend: The Callback Landing Page
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) });
}
});
The returned
wsais the same JWT contract as push, andverifyWsais used identically.
Security Constraints (Required Reading)
redirect_urimust be same-origin as the registered app: its scheme + host must exactly equalclient_id./authorizeis a browser navigation endpoint and cannot return a JSON error; whenredirect_uriis 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.- PKCE is enforced: only
code_challenge_method=S256is accepted (case-sensitive), andcode_challenge = base64url(sha256(code_verifier))with no padding. The current version does not use aclient_secret; PKCE binds the "session that initiated" to the "session that exchanges." - One-time
code: stored in Redis with a 10-minute TTL and atomically consumed on exchange, so it cannot be replayed. A replay/expiry reports403209 invalid_grant, and acode_verifiermismatch reports403210 invalid_verifier. state(CSRF): the SDK storesstateandcode_verifierinsessionStorage, and only continues on callback ifstatematches.- 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_urimissing or cross-origin, an invalidcode_challenge, or acode_challenge_methodother thanS256) 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/tokenphase.
| 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.
