04 · การเข้าสู่ระบบแบบ Pull(Login with GPTBots Workspace / M-Auth)

04 · การเข้าสู่ระบบแบบ Pull(Login with GPTBots Workspace / M-Auth)

เมื่อแอปส่วนขยายของนักพัฒนาเป็นเว็บไซต์อิสระ และต้องการให้บริการปุ่ม 「Login with GPTBots Workspace」 หนึ่งปุ่ม ผู้ใช้คลิกแล้วไปที่การล็อกอิน GPTBots เลือกพื้นที่ทำงาน แล้วนำสถานะล็อกอินและข้อมูลตัวตนของผู้ใช้ที่ล็อกอินกลับมา

ใช้ OAuth2 authorization code + PKCE: เบราว์เซอร์ได้แค่ code แบบใช้ครั้งเดียว ส่วน wsa ตัวจริงแบ็กเอนด์ของคุณเป็นผู้แลกด้วย code + PKCE code_verifier, wsa ไม่มีวันเข้าไปใน URL / ประวัติ / Referer ของเบราว์เซอร์ — ปลอดภัยกว่าแบบ Push

การตรวจสอบหลังได้ wsa มาแล้ว เหมือนกันทุกประการกับแบบ Push(ดู 05)บทความนี้พูดถึงแค่「วิธีรับ wsa มา」เท่านั้น

1. ลำดับเหตุการณ์แบบครบวงจร

loading...
sequenceDiagram
    participant WS as หน้า「ส่วนขยาย」ของพื้นที่ทำงาน
    participant GB as แพลตฟอร์ม GPTBots
    participant FE as หน้า Landing ของแอปส่วนขยาย
    participant BE as แบ็กเอนด์ของแอปส่วนขยาย

    Note over WS: ผู้ใช้คลิกไอคอนแอป
    WS->>GB: POST sign-token
    Note over GB: ตรวจสอบว่าผู้คลิกเป็นสมาชิกของ workspace นี้<br/>ใช้คีย์ลับลงลายเซ็น wsa(JWT อายุ 5 นาที, aud=host ของคุณ)
    GB-->>WS: คืนค่า wsa
    WS->>FE: เปิด app_home_url?wsa=JWT
    Note over FE: consumeHandoff(), อ่าน ?wsa=
    FE->>BE: POST /session/exchange(พก wsa มา)
    Note over BE: verifyWsa() → identity<br/>สร้าง session ของตัวเอง
    BE-->>FE: identity
    Note over FE: history.replaceState ลบ ?wsa=

Endpoint ของแพลตฟอร์ม

วัตถุประสงค์ เมธอด เส้นทาง
ทางเข้าขออนุญาต(นำทางในเบราว์เซอร์) GET /api/console/account/extension-app/authorize
แลกโทเค็น(แบ็กเอนด์ → แบ็กเอนด์) POST /api/console/account/extension-app/token

พารามิเตอร์ /authorize

พารามิเตอร์ จำเป็น คำอธิบาย
client_id ใช่ URL หน้าเข้าแอปส่วนขยาย(app home URL)
redirect_uri ใช่ ที่อยู่ callback ซึ่ง host ต้องอยู่โดเมนเดียวกับ client_id(scheme + host เดียวกัน)
state ใช่ สตริงสุ่ม CSRF, ตอน callback จะพากลับมาตรวจสอบตามเดิม
code_challenge ใช่ base64url(sha256(code_verifier)), ไม่มี padding
code_challenge_method ใช่ รับเฉพาะ S256(แยกตัวพิมพ์ใหญ่-เล็ก)
workspace_id ไม่ เลือกพื้นที่ทำงานล่วงหน้า ข้ามหน้าเลือกองค์กร

/authorize จะ 302 ตามสถานะล็อกอินไปที่: หน้าล็อกอิน GPTBots(ยังไม่ล็อกอิน)/ หน้าเลือกองค์กร(ล็อกอินแล้วแต่ยังไม่เลือกองค์กร)/ redirect_uri?code&state(เลือกองค์กรแล้ว)

คำขอและการตอบกลับ /token

body ของคำขอ(แบ็กเอนด์เรียกใช้):

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

                    
บล็อกโค้ดนี้ในหน้าต่างลอย

การตอบกลับเมื่อสำเร็จ:

{ "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 }
}

                    
บล็อกโค้ดนี้ในหน้าต่างลอย

การเชื่อมต่อ SDK

ฟรอนต์เอนด์: เริ่มการล็อกอิน(คลิกปุ่ม)

import { startWorkspaceLogin } from '@gptbots/workspace-extension-sdk'; // เมื่อคลิก「Login with GPTBots Workspace」: await startWorkspaceLogin({ authorizeUrl: 'https://www.gptbots.ai/api/console/account/extension-app/authorize', clientId: 'https://app.example.com/land', // = app home URL ที่คุณลงทะเบียน redirectUri: 'https://app.example.com/callback', // host ต้องอยู่โดเมนเดียวกับ clientId // workspaceId: 'p-xxx', // ตัวเลือก: เลือกพื้นที่ทำงานล่วงหน้า ข้ามหน้าเลือกองค์กร // state: '...', // ตัวเลือก: ค่าเริ่มต้นสร้าง CSRF state สุ่ม 16 ไบต์อัตโนมัติ }); // SDK ทำอัตโนมัติ: สร้าง PKCE(verifier→challenge), เก็บ verifier+state ใน sessionStorage, // ตรวจสอบว่า authorizeUrl เป็น absolute URL, ต้องอยู่ในบริบทที่ปลอดภัย(HTTPS/localhost), แล้วเปลี่ยนเส้นทางไปที่ /authorize
                      
                      import { startWorkspaceLogin } from '@gptbots/workspace-extension-sdk';

// เมื่อคลิก「Login with GPTBots Workspace」:
await startWorkspaceLogin({
  authorizeUrl: 'https://www.gptbots.ai/api/console/account/extension-app/authorize',
  clientId: 'https://app.example.com/land',      // = app home URL ที่คุณลงทะเบียน
  redirectUri: 'https://app.example.com/callback', // host ต้องอยู่โดเมนเดียวกับ clientId
  // workspaceId: 'p-xxx',   // ตัวเลือก: เลือกพื้นที่ทำงานล่วงหน้า ข้ามหน้าเลือกองค์กร
  // state: '...',           // ตัวเลือก: ค่าเริ่มต้นสร้าง CSRF state สุ่ม 16 ไบต์อัตโนมัติ
});
// SDK ทำอัตโนมัติ: สร้าง PKCE(verifier→challenge), เก็บ verifier+state ใน sessionStorage,
// ตรวจสอบว่า authorizeUrl เป็น absolute URL, ต้องอยู่ในบริบทที่ปลอดภัย(HTTPS/localhost), แล้วเปลี่ยนเส้นทางไปที่ /authorize

                    
บล็อกโค้ดนี้ในหน้าต่างลอย

ฟรอนต์เอนด์: หน้า Landing ของ callback

loading...
sequenceDiagram
    participant FE as ฟรอนต์เอนด์ของแอปส่วนขยาย
    participant GB as GPTBots
    participant BE as แบ็กเอนด์ของแอปส่วนขยาย

    Note over FE: startWorkspaceLogin()<br/>สร้าง PKCE(verifier→challenge)<br/>เก็บ sessionStorage, 302 เปลี่ยนเส้นทาง
    FE->>GB: GET /authorize
    alt ยังไม่ล็อกอิน
        GB-->>FE: 302 ไปหน้าล็อกอิน GPTBots(ใช้การล็อกอินเดิมซ้ำ)
    else ล็อกอินแล้ว, ยังไม่เลือกองค์กร
        GB-->>FE: 302 หน้าเลือกองค์กร
    else ล็อกอินแล้ว, เลือกองค์กรแล้ว
        Note over GB: ออก code ใช้ครั้งเดียว(Redis,<br/>ผูกกับ account/project/app/redirect/challenge)
        GB-->>FE: 302 redirect_uri?code&state
    end
    Note over FE: completeWorkspaceLogin()<br/>ตรวจสอบ state(CSRF), ดึง verifier ออกมา
    FE->>BE: POST {code, codeVerifier}
    Note over BE: exchangeWorkspaceCode()
    BE->>GB: POST /token {code, verifier}
    Note over GB: ตรวจสอบ code(ใช้ครั้งเดียว GETDEL) + PKCE<br/>ลงลายเซ็น wsa
    GB-->>BE: คืนค่า wsa
    Note over BE: verifyWsa(wsa) → สร้าง session ของตัวเอง
    BE-->>FE: identity

แบ็กเอนด์: แลก wsa และตรวจสอบ

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, // ค่าเริ่มต้น 10s, กันแพลตฟอร์มตอบช้าค้างคำขอของคุณ; ส่ง 0 เพื่อปิด }); 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,   // ค่าเริ่มต้น 10s, กันแพลตฟอร์มตอบช้าค้างคำขอของคุณ; ส่ง 0 เพื่อปิด
    });
    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) });
  }
});

                    
บล็อกโค้ดนี้ในหน้าต่างลอย

wsa ที่คืนมามีสัญญา JWT ใบเดียวกันกับแบบ Push, การใช้ verifyWsa เหมือนกันทุกตัวอักษร

ข้อจำกัดด้านความปลอดภัย(ต้องอ่าน)

  1. redirect_uri ต้องอยู่โดเมนเดียวกับแอปที่ลงทะเบียน: scheme + host ต้องเท่ากับ client_id อย่างแม่นยำ /authorize เป็น endpoint นำทางในเบราว์เซอร์ ไม่สามารถคืนข้อผิดพลาดแบบ JSON ได้; เมื่อ redirect_uri ขาดหาย / ไม่ใช่ http(s) / คนละโดเมน จะไม่มีวันเปลี่ยนเส้นทางไปยังที่อยู่ที่ไม่ผ่านการตรวจสอบ แต่จะกลับไปหน้าเลือกองค์กรพร้อม ?error=invalid_request นี่คือกุญแจสำคัญในการป้องกัน open redirect / การรั่วไหลของโทเค็น
  2. บังคับใช้ PKCE: รับเฉพาะ code_challenge_method=S256(แยกตัวพิมพ์ใหญ่-เล็ก), code_challenge = base64url(sha256(code_verifier)) ไม่มี padding เวอร์ชันปัจจุบันไม่ใช้ client_secret แต่ใช้ PKCE ผูก「เซสชันที่เริ่ม」กับ「เซสชันที่แลก」
  3. code ใช้ครั้งเดียว: เก็บใน Redis, TTL 10 นาที, บริโภคแบบ atomic ตอนแลก, ไม่สามารถ replay ได้ replay/หมดอายุจะรายงาน 403209 invalid_grant, code_verifier ไม่ตรงจะรายงาน 403210 invalid_verifier
  4. state(CSRF): SDK เก็บ state และ code_verifier ใน sessionStorage, ตอน callback จะตรวจสอบว่า state ตรงกันจึงดำเนินต่อ
  5. ขอบเขตองค์กร: ตอนขออนุญาตจะตรวจสอบว่าบัญชีเป็นสมาชิกของพื้นที่ทำงานที่เลือก และแอปนั้นใช้งานได้ในองค์กรนั้น(แอปส่วนขยายที่องค์กรใช้เองมองเห็นได้เฉพาะองค์กรที่เป็นเจ้าของ); หลังผู้ดูแลองค์กรปิดใช้งานแอปใด แม้แอปนั้นยังอยู่ในพจนานุกรมของแพลตฟอร์มก็จะไม่ออกโทเค็นให้องค์กรนั้น

รหัสข้อผิดพลาดในขั้นแลก /token

ข้อผิดพลาดเชิงโครงสร้างของ /authorizeclient_id/redirect_uri ขาดหายหรือคนละโดเมน, code_challenge ไม่ถูกต้อง, code_challenge_method ไม่ใช่ S256ไม่คืน JSON แต่จะ 302 กลับหน้าเลือกองค์กรพร้อม ?error=invalid_request ตารางด้านล่างระบุเฉพาะรหัสข้อผิดพลาด JSON ในขั้น /token

code ความหมาย เงื่อนไขที่ทำให้เกิด
403209 Invalid grant code ขาดหาย, หมดอายุ หรือถูกใช้ไปแล้ว(replay)
403210 Invalid verifier PKCE code_verifier ไม่ตรงกับ code_challenge

ค่าของ ?error= บน callback URL: invalid_request(ข้อผิดพลาดเชิงโครงสร้าง)/ access_denied(ไม่ใช่สมาชิกหรือแอปใช้งานไม่ได้ในองค์กรนั้น)/ server_error(ล้มเหลวโดยไม่คาดคิด) completeWorkspaceLogin ของ SDK จะโยนมันเป็น WorkspaceLoginError('AuthorizeError')

ขั้นถัดไป: ไม่ว่าจะแบบ Push หรือ Pull เรื่องการตรวจสอบและความปลอดภัยให้อ่าน 05-การตรวจสอบโทเค็นและความปลอดภัย; API ครบถ้วนดูที่ 06-SDK-API-อ้างอิง