การเชื่อมต่อแบบ Push(การจับมือยืนยันตัวตน / Push Handoff)

การเชื่อมต่อแบบ Push(การจับมือยืนยันตัวตน / Push Handoff)

สถานการณ์: ผู้ใช้คลิกแอปส่วนขยายของนักพัฒนาในหน้า พื้นที่ทำงาน → ส่วนขยาย แพลตฟอร์มจะผลักตัวตนของผู้ใช้ในรูปแบบ ?wsa=<JWT> ไปยังหน้า Landing ของแอปส่วนขยายเป้าหมาย นักพัฒนาบริโภคที่หน้า Landing และตรวจสอบที่แบ็กเอนด์ แล้วแลกเป็นเซสชันของตัวเอง

ในโหมดนี้ นักพัฒนาไม่จำเป็นต้องเรียกใช้ endpoint ลงลายเซ็นของแพลตฟอร์ม — นั่นเป็นสิ่งที่ฟรอนต์เอนด์ของพื้นที่ทำงานเรียกใช้เองเมื่อผู้ใช้คลิก นักพัฒนารับผิดชอบเพียง「รับมาและตรวจสอบ」

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

loading...
sequenceDiagram
    autonumber
    actor User as ผู้ใช้เข้าถึงแอปส่วนขยาย
    participant GB as แพลตฟอร์ม GPTBots
    participant FE as แอปส่วนขยาย-หน้า Landing
    participant BE as แอปส่วนขยาย-แบ็กเอนด์

    User->>GB: คลิกไอคอนแอป(POST sign-token)
    GB->>GB: ตรวจสอบว่าผู้คลิกเป็นสมาชิกของ workspace นี้
    GB->>GB: ใช้คีย์ลับลงลายเซ็น wsa<br/>(JWT อายุ 5 นาที, aud=host ของแอปส่วนขยาย)
    GB->>FE: เปิด app_home_url?wsa=<JWT>

    Note over FE: consumeHandoff()<br/>อ่าน ?wsa=, POST ไปยังแบ็กเอนด์ของแอปส่วนขยาย
    FE->>BE: POST /session/exchange { wsa }
    BE->>BE: verifyWsa() → identity
    BE->>BE: สร้าง session ของตัวเอง
    BE-->>FE: คืนค่า session(Set-Cookie)

    Note over FE: history.replaceState ลบ ?wsa=

กฎการสร้าง URL เปลี่ยนเส้นทางของแพลตฟอร์ม(นักพัฒนาไม่ต้องนำไปสร้างเอง):

  • จับคู่แอปส่วนขยายเป้าหมายจากข้อมูลลงทะเบียนโดยจับคู่ app_home_url แบบแม่นยำ(URL ที่ไม่ได้ลงทะเบียนจะถูกปฏิเสธการลงลายเซ็นทั้งหมด เพื่อป้องกันการรั่วไหลของตัวตน)
  • ตรวจสอบว่าผู้คลิกเป็นสมาชิกของพื้นที่ทำงานนั้น(workspace_id)จริง
  • หาก app_home_url มี query อยู่แล้ว จะต่อ wsa ด้วย &; ค่า wsa ถูก URL-encode แล้ว คุณอ่านมาแล้วไม่ต้อง decode เอง

ฟรอนต์เอนด์: บริโภค wsa ที่หน้า Landing

ใช้ SDK(แนะนำ)

import { consumeHandoff } from '@gptbots/workspace-extension-sdk'; try { const identity = await consumeHandoff({ exchangeUrl: '/session/exchange', // อินเทอร์เฟซตรวจสอบของแบ็กเอนด์คุณเอง // search: location.search, // ค่าเริ่มต้นอ่าน location.search // paramName: 'wsa', // ชื่อพารามิเตอร์เริ่มต้น wsa // strip: true, // ค่าเริ่มต้นลบ wsa ออกจาก URL เมื่อสำเร็จ }); bootYourApp(identity); } catch (e) { // ไม่มี wsa(ผู้ใช้เข้าถึงโดยตรง)หรือแบ็กเอนด์ตรวจสอบไม่ผ่าน showLoginOrError(e); }
                      
                      import { consumeHandoff } from '@gptbots/workspace-extension-sdk';

try {
  const identity = await consumeHandoff({
    exchangeUrl: '/session/exchange', // อินเทอร์เฟซตรวจสอบของแบ็กเอนด์คุณเอง
    // search:   location.search,     // ค่าเริ่มต้นอ่าน location.search
    // paramName: 'wsa',              // ชื่อพารามิเตอร์เริ่มต้น wsa
    // strip:     true,              // ค่าเริ่มต้นลบ wsa ออกจาก URL เมื่อสำเร็จ
  });
  bootYourApp(identity);
} catch (e) {
  // ไม่มี wsa(ผู้ใช้เข้าถึงโดยตรง)หรือแบ็กเอนด์ตรวจสอบไม่ผ่าน
  showLoginOrError(e);
}

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

consumeHandoff ทำสี่สิ่ง: ① อ่าน ?wsa= ② POST { wsa } ไปยัง exchangeUrlเมื่อสำเร็จ history.replaceState ลบ ?wsa= ④ คืนค่า identity ที่แบ็กเอนด์ส่งกลับ

จังหวะการลบ: โทเค็นจะถูกลบออกจาก URL หลังแลกสำเร็จเท่านั้น เพื่อให้ความล้มเหลวชั่วคราวครั้งเดียวสามารถรีเฟรชลองใหม่ได้ นี่คือโทเค็นใช้ครั้งเดียวอายุ 5 นาที; หากนักพัฒนาต้องการให้ลบทันทีแม้ล้มเหลว สามารถ catch แล้วเรียก stripHandoffToken() เอง

ไม่สร้างเซสชัน อ่านตัวตนอย่างเดียว(receive-only)

import { readHandoffToken, stripHandoffToken } from '@gptbots/workspace-extension-sdk'; const token = readHandoffToken(); // ฟังก์ชันบริสุทธิ์ คืนค่าสตริง JWT ดั้งเดิมหรือ null if (token) { // ยังแนะนำให้ส่ง token ไปให้แบ็กเอนด์ verifyWsa ก่อนจึงเชื่อเนื้อหา (อย่า parse JWT ที่ฟรอนต์เอนด์แล้วถือเป็นตัวตนที่เชื่อถือได้) await fetch('/telemetry/visit', { method: 'POST', body: JSON.stringify({ wsa: token }) }); } stripHandoffToken(); // อย่างไรก็ตามให้ลบ wsa ออกจาก URL
                      
                      import { readHandoffToken, stripHandoffToken } from '@gptbots/workspace-extension-sdk';

const token = readHandoffToken();   // ฟังก์ชันบริสุทธิ์ คืนค่าสตริง JWT ดั้งเดิมหรือ null
if (token) {
  // ยังแนะนำให้ส่ง token ไปให้แบ็กเอนด์ verifyWsa ก่อนจึงเชื่อเนื้อหา (อย่า parse JWT ที่ฟรอนต์เอนด์แล้วถือเป็นตัวตนที่เชื่อถือได้)
  await fetch('/telemetry/visit', { method: 'POST', body: JSON.stringify({ wsa: token }) });
}
stripHandoffToken();                // อย่างไรก็ตามให้ลบ wsa ออกจาก URL

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

⚠️ อย่าถอด JWT ที่ฟรอนต์เอนด์แล้วถือเป็นตัวตนที่เชื่อถือได้ ลายเซ็นของ JWT ต้องใช้คีย์ลับเท่านั้นจึงจะตรวจสอบได้ และคีย์ลับอยู่ที่แบ็กเอนด์เท่านั้น การ parse ที่ฟรอนต์เอนด์ใช้ได้แค่เป็น placeholder แสดงผลแบบ「ไม่ปลอดภัย」เท่านั้น การตัดสินใจเรื่องสิทธิ์ใด ๆ ต้องยึดผลของ verifyWsa ที่แบ็กเอนด์เป็นหลัก


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

3.1 ใช้ SDK(แนะนำ)

import { verifyWsa, WsaVerificationError } from '@gptbots/workspace-extension-verify'; app.post('/session/exchange', (req, res) => { try { const identity = verifyWsa(req.body.wsa, { secret: process.env.EXTENSION_APP_SECRET, // คีย์ลับ per-app ของ Tier2 หรือคีย์ลับร่วมของ Tier1 audience: 'app.example.com', // host ของแอปส่วนขยาย ต้องเท่ากับ aud // issuer: 'gptbots-workspace', // ค่าเริ่มต้น // leewaySeconds: 30, // ระยะผ่อนผันการคลาดเคลื่อนของนาฬิกา ค่าเริ่มต้น 30s // algorithms: ['HS256'], // ค่าเริ่มต้น }); // แยกผู้เช่าหลายราย: จัดคำขอให้อยู่ภายใต้ identity.workspaceId const sid = createSession(identity); res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' }); res.json(identity); } catch (e) { const code = e instanceof WsaVerificationError ? e.code : 'Error'; res.status(401).json({ code }); } });
                      
                      import { verifyWsa, WsaVerificationError } from '@gptbots/workspace-extension-verify';

app.post('/session/exchange', (req, res) => {
  try {
    const identity = verifyWsa(req.body.wsa, {
      secret: process.env.EXTENSION_APP_SECRET, // คีย์ลับ per-app ของ Tier2 หรือคีย์ลับร่วมของ Tier1
      audience: 'app.example.com',              // host ของแอปส่วนขยาย ต้องเท่ากับ aud
      // issuer: 'gptbots-workspace',          // ค่าเริ่มต้น
      // leewaySeconds: 30,                    // ระยะผ่อนผันการคลาดเคลื่อนของนาฬิกา ค่าเริ่มต้น 30s
      // algorithms: ['HS256'],                // ค่าเริ่มต้น
    });

    // แยกผู้เช่าหลายราย: จัดคำขอให้อยู่ภายใต้ identity.workspaceId
    const sid = createSession(identity);
    res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' });
    res.json(identity);
  } catch (e) {
    const code = e instanceof WsaVerificationError ? e.code : 'Error';
    res.status(401).json({ code });
  }
});

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

ไม่ใช้ SDK

wsa เป็น HS256 JWT มาตรฐาน ไลบรารี JWT ของภาษาใดก็ตรวจสอบได้ ตัวอย่าง Java / Node / Python ดูที่ 05-การตรวจสอบโทเค็นและความปลอดภัย §3 ไม่ว่าจะใช้หรือไม่ใช้ SDK ทั้งสี่รายการคือ ลายเซ็น / iss / aud / exp ต้องตรวจสอบทั้งหมด

ลงทะเบียนแอปส่วนขยาย(รับคีย์ลับ)

OWNER/ADMIN ของพื้นที่ทำงาน: พื้นที่ทำงาน → การจัดการพื้นที่ → แอปส่วนขยาย → เพิ่ม กรอก:

ฟิลด์ คำอธิบาย
ชื่อแอป ชื่อที่แสดง แนะนำ ≤ 12 ตัวอักษรจีนเพื่อเลี่ยงการตัดข้อความ
ไอคอนแอป ไอคอนสี่เหลี่ยมจัตุรัส แนะนำ ≥ 128×128
URL หน้าเข้าแอป app_home_url ของคุณ ใช้เป็นคีย์เฉพาะ ตอนลงลายเซ็นจะจับคู่แบบเข้มงวดตามสตริงเต็ม
โหมดยืนยันสิทธิ์ เลือก workspace_account(ต้องมีการส่งข้อมูลตัวตน)

หลังส่งข้อมูลแล้ว ระบบจะแสดง App Secret เป็นข้อความธรรมดาเพียงครั้งเดียว คัดลอกเก็บไว้ทันที(เมื่อปิดแล้วทำได้เพียงหมุนเวียนคีย์ใหม่)

Checklist หน้า Landing

  • เมื่อได้รับ ?wsa= ให้POST ไปยังบริการแบ็กเอนด์ของแอปส่วนขยายก่อนเพื่อตรวจสอบ แล้วจึงเชื่อเนื้อหา
  • เมื่อตรวจสอบผ่านทันที history.replaceState ลบ wsaconsumeHandoff ทำให้เป็นค่าเริ่มต้นแล้ว)
  • แลกเป็นเซสชันของตัวเอง คำขอ XHR/fetch/img ถัดไปไม่ส่งผ่าน wsa อีก
  • จัดการกรณีแยก「ผู้ใช้เข้าถึงโดยตรง ไม่มี wsa」(นำไปล็อกอินหรือแบบไม่ระบุตัวตน)
  • ตรวจสอบไม่ผ่านให้แสดงข้อความที่อ่านเข้าใจได้ตาม WsaVerificationError.code

รายละเอียดความปลอดภัยและการตรวจสอบหลายภาษา: การตรวจสอบโทเค็นและความปลอดภัย อยากเปลี่ยนเป็น「ปุ่มล็อกอินในแอป」: การเข้าสู่ระบบแบบ Pull