Sign in with Minecraft
ให้ผู้เล่นยืนยันตัวตนด้วยบัญชี Minecraft: /authorize, Redirect, Webhook, /auth/token และการตรวจลายเซ็น
Sign in with Minecraft คืออะไร
ให้ผู้เล่นพิสูจน์ต่อแอปของคุณว่าเป็นเจ้าของบัญชี Minecraft ใด โดยแอปได้รับ uuid และชื่อที่ผ่านการยืนยันกับ Mojang แล้ว รูปแบบเดียวกับการ Login with Discord แต่ตัวตนที่พิสูจน์คือบัญชี Minecraft การพิสูจน์ทำที่ Launcher เท่านั้น (ผ่านการ join session กับ Mojang) จึงมีขั้นตอนเด้งจากเว็บไปที่ Launcher
| ขั้นตอน | ใครทำ | เกิดอะไรขึ้น |
|---|---|---|
| 1 | แอปของคุณ | ส่งผู้เล่นไปหน้า /authorize ของ Dashboard พร้อมพารามิเตอร์ |
| 2 | ผู้เล่น (เว็บ) | เห็นชื่อแอป ข้อมูลที่จะแชร์ และโดเมนปลายทาง กด ดำเนินการต่อด้วย Launcher เว็บแสดงรหัส 4 ตัว |
| 3 | ผู้เล่น (Launcher) | Launcher เปิดขึ้น ผู้เล่นกรอกรหัส 4 ตัวจากเว็บแล้วกดยืนยัน |
| 4 | ระบบ | ส่งผลให้แอปตามโหมด: Redirect (redirect พร้อม code) หรือ Webhook (POST ที่เซ็นกำกับ) |
| 5 | server ของคุณ | Redirect: นำ code ไปแลกเป็นข้อมูลผู้เล่นที่ POST /auth/token / Webhook: ได้ข้อมูลมากับ POST เลย |
ป้องกันการหลอกให้ผู้เล่นกดยืนยันแทนผู้อื่น ถ้าคนร้ายเริ่มคำขอบนเครื่องของตัวเองแล้วส่งลิงก์เปิด Launcher ให้เหยื่อ เหยื่อไม่เห็นรหัสบนหน้าเว็บของคนร้าย จึงยืนยันไม่ได้ กรอกรหัสผิดครบ 5 ครั้ง คำขอถูกยกเลิก
ตั้งค่าแอป
ในหน้าแอป ส่วน Auth เลือกโหมดและกรอกที่อยู่ (ดู Developer zone):
| การตั้งค่า | ชนิด | ข้อกำหนด |
|---|---|---|
callbackType | "REDIRECT" | "WEBHOOK" | ค่าเริ่มต้น REDIRECT |
redirectUris | string[] | ใช้กับ Redirect ได้ไม่เกิน 10 ที่อยู่ แต่ละที่อยู่ไม่เกิน 500 ตัวอักษร ต้องเป็น https:// (หรือ http://localhost / http://127.0.0.1 สำหรับพัฒนา) และไม่มี #fragment ต้องมีอย่างน้อยหนึ่งที่อยู่เมื่อใช้ Redirect |
webhookUrl | string | null | ใช้กับ Webhook ต้องเป็น https:// ไม่เกิน 500 ตัวอักษร และต้องชี้ไปที่อยู่สาธารณะ (ดูหัวข้อ Webhook) |
authScopes | "identify"[] | scope ที่แอปขอได้ ตอนนี้มีค่าเดียวคือ identify |
1. ส่งผู้เล่นไปหน้าขอสิทธิ์ (/authorize)
เป็นหน้าเว็บของ Dashboard (โดเมนเดียวกับที่คุณเปิดเอกสารนี้ ไม่ใช่โดเมนของ API) ให้ผู้เล่นเปิดผ่านเบราว์เซอร์ด้วยการ redirect หรือปุ่มลิงก์ ไม่ต้องใช้ client_secret ในขั้นนี้
{Dashboard}/authorize?client_id=…&scope=identify&state=…&redirect_uri=…https://<โดเมนของเว็บนี้>/authorize
?client_id=ckx1abcd0000
&scope=identify
&state=3f9c1e7a52b84d0e8a6c
&redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fminecraft%2Fcallbackพารามิเตอร์ (query string)
| ชื่อ | ชนิด | จำเป็น | รายละเอียด |
|---|---|---|---|
client_id | string | ใช่ | client_id ของแอป |
redirect_uri | string (URL) | ใช่ ถ้าเป็น Redirect ไม่ใช้ ถ้าเป็น Webhook | ต้องตรงทุกตัวอักษรกับหนึ่งใน redirectUris ที่ลงทะเบียน (รวมถึง / ท้ายและ query) ไม่เกิน 500 ตัวอักษร ต้อง URL-encode ในลิงก์ ถ้าไม่ตรง หน้าเว็บแจ้งข้อผิดพลาดและไม่ redirect ไปไหน |
scope | string | ไม่ (ค่าเริ่มต้น identify) | รายชื่อ scope คั่นด้วยช่องว่าง , หรือ + ต้องเป็น scope ที่แอปเปิดไว้ ถ้ามี scope ที่ไม่รู้จักตอบ 400 ถ้าเป็น scope ที่แอปไม่ได้เปิดตอบ 403 |
state | string (8-200 ตัวอักษร) | ใช่ | ค่าสุ่มที่คุณสร้างและเก็บไว้ในเซสชันของผู้เล่น ระบบส่งคืนให้ตรงตัวพร้อมผลลัพธ์ เพื่อให้คุณเทียบว่าเป็นคำขอของผู้เล่นคนเดียวกัน (ป้องกัน CSRF) ถ้าไม่ส่งหรือสั้นกว่า 8 ตัว ปุ่มดำเนินการต่อจะขึ้นข้อผิดพลาด |
สร้าง state ใหม่แบบสุ่ม (เช่น 16 ไบต์จาก CSPRNG) ทุกครั้งที่เริ่ม เก็บไว้ผูกกับเบราว์เซอร์ของผู้เล่น และปฏิเสธผลที่ state ไม่ตรง
2a. โหมด Redirect
เมื่อผู้เล่นตอบใน Launcher หน้าเว็บ redirect เบราว์เซอร์ไปที่ redirect_uri ของคุณ โดยเติม query ต่อท้าย (ถ้า URI มี query อยู่แล้วจะคงไว้ แล้วเติมเพิ่ม):
| ผลลัพธ์ | query ที่เติม | ความหมาย |
|---|---|---|
| ผู้เล่นยืนยัน | code=<code>&state=<state> | code เป็นสตริงทึบ (opaque) ใช้ได้ครั้งเดียว อายุ 5 นาที นับจากผู้เล่นกดยืนยัน |
| ผู้เล่นปฏิเสธ | error=access_denied&state=<state> | ไม่มีข้อมูลผู้เล่นถูกส่ง |
| ผู้เล่นกดยกเลิกที่หน้าเว็บ | error=access_denied&state=<state> | เหมือนปฏิเสธ |
https://example.com/auth/minecraft/callback?code=8Qm3vT0x2aYb7Jk9LwZp4RtN1sUe6HcD&state=3f9c1e7a52b84d0e8a6cถ้าผู้เล่นปิดหน้าหรือปล่อยจนหมดเวลา (คำขออายุ 10 นาทีนับจากกดดำเนินการต่อ) จะไม่มีการ redirect กลับ ให้แอปรองรับกรณีนี้ด้วยการหมดอายุของ state ฝั่งคุณเอง
แลก code เป็นข้อมูลผู้เล่น
/api/v1/auth/tokenต้องมี Basic auth ของแอป (อ่าน ภาพรวม) เรียกจาก server ของคุณเท่านั้น
Request body (application/json):
| ฟิลด์ | ชนิด | จำเป็น | ข้อกำหนด |
|---|---|---|---|
code | string | ใช่ | ความยาว 1-200 ตัวอักษร ค่า code จาก query ของ redirect ตรงๆ |
interface TokenRequest {
code: string; // 1..200 ตัวอักษร
}
interface TokenResponse {
statusCode: 200;
data: {
state: string; // state ที่คุณส่งไปตอนเริ่ม ต้องเทียบกับที่เก็บไว้
scopes: "identify"[]; // scope ที่ผู้เล่นอนุญาต
player: {
uuid: string; // 32 หลักตัวพิมพ์เล็ก ไม่มีขีด
name: string; // ชื่อผู้เล่น Minecraft ณ ตอนยืนยัน
};
};
}POST /api/v1/auth/token HTTP/1.1
Authorization: Basic Y2t4MWFiY2QwMDAwOmRtc2FwcF8uLi4=
Content-Type: application/json
{ "code": "8Qm3vT0x2aYb7Jk9LwZp4RtN1sUe6HcD" }{
"statusCode": 200,
"data": {
"state": "3f9c1e7a52b84d0e8a6c",
"scopes": ["identify"],
"player": {
"uuid": "069a79f444e94726a5befca90e38aaf5",
"name": "Notch"
}
}
}| สถานะ | message | เมื่อไหร่ |
|---|---|---|
400 | Invalid or expired code | code ไม่มีอยู่ หมดอายุ (เกิน 5 นาที) ถูกใช้ไปแล้ว หรือเป็น code ของแอปอื่น (code ของแอปอื่นถูกทำลายเมื่อมีคนลองใช้) |
400 | Validation failed | ไม่ส่ง code หรือชนิด/ความยาวผิด (มีรายการ errors) |
401 | Invalid client credentials | client_id หรือ secret ผิด |
// ตัวอย่าง Express: callback ของ Redirect
app.get("/auth/minecraft/callback", async (req, res) => {
const { code, state, error } = req.query;
if (error) return res.redirect("/login?error=" + encodeURIComponent(String(error)));
if (!code || state !== req.session.authState) return res.status(400).send("state ไม่ตรง");
delete req.session.authState; // ใช้ครั้งเดียว
const r = await fetch(API + "/api/v1/auth/token", {
method: "POST",
headers: {
Authorization: "Basic " + Buffer.from(CLIENT_ID + ":" + CLIENT_SECRET).toString("base64"),
"Content-Type": "application/json",
},
body: JSON.stringify({ code }),
});
if (!r.ok) return res.status(400).send("ยืนยันตัวตนไม่สำเร็จ");
const { data } = await r.json();
req.session.player = data.player; // { uuid, name }
res.redirect("/");
});2b. โหมด Webhook
server ของเรายิง POST ไปที่ webhookUrl ของคุณเมื่อผู้เล่นยืนยัน ผู้เล่นไม่ถูกส่งต่อไปที่เว็บของคุณ (หน้าเว็บของเราแจ้งว่าสำเร็จ) ไม่ต้องเรียก /auth/token เพราะข้อมูลผู้เล่นมากับ POST ตรงๆ ไม่ต้องส่ง redirect_uriตัวแอปจับคู่ผลกับผู้ใช้ของตัวเองด้วย state (เช่นตอนสร้างลิงก์ ให้เก็บ state ผูกกับผู้ใช้ไว้ในฐานข้อมูลของคุณ) กรณีที่ผู้เล่นปฏิเสธหรือไม่ตอบ จะไม่มีการส่ง webhook
คำขอที่คุณได้รับ
| ส่วน | ค่า |
|---|---|
| Method / URL | POST ไปที่ webhookUrl ของแอป |
| Content-Type | application/json |
| User-Agent | DimensionLauncherManager-Webhook/1.0 |
| X-DMS-Timestamp | เวลาที่ส่ง เป็น Unix timestamp หน่วยวินาที (สตริงตัวเลข) เช่น 1790000000 |
| X-DMS-Signature | sha256=<hex> ลายเซ็น HMAC ดูหัวข้อถัดไป |
interface AuthCompletedWebhook {
event: "auth.completed"; // ตอนนี้มีเหตุการณ์เดียว
state: string; // state ที่แอปส่งตอนเริ่ม
scopes: "identify"[]; // scope ที่ผู้เล่นอนุญาต
player: {
uuid: string; // 32 หลักตัวพิมพ์เล็ก ไม่มีขีด
name: string; // ชื่อผู้เล่น Minecraft ณ ตอนยืนยัน
};
}{
"event": "auth.completed",
"state": "3f9c1e7a52b84d0e8a6c",
"scopes": ["identify"],
"player": { "uuid": "069a79f444e94726a5befca90e38aaf5", "name": "Notch" }
}ตรวจลายเซ็น
- อ่าน body ดิบ (ก่อน parse JSON) เป็นสตริง และ header
X-DMS-Timestamp,X-DMS-Signature - คำนวณกุญแจ:
key = SHA-256(client_secret)เป็นสตริง hex ตัวพิมพ์เล็ก (ระบบเก็บเฉพาะ hash ของ secret จึงใช้ hash เป็นกุญแจ แอปของคุณหาค่าเดียวกันได้จาก secret) - คำนวณ
expected = "sha256=" + hex(HMAC-SHA256(key, timestamp + "." + rawBody)) - เทียบแบบ constant-time กับ
X-DMS-Signature - ปฏิเสธถ้า timestamp ต่างจากเวลาปัจจุบันเกินไม่กี่นาที (แนะนำ 5 นาที) กันการส่งซ้ำ และกันการประมวลผล
stateเดิมซ้ำ
// Node.js (Express): ต้องใช้ body ดิบ
import crypto from "node:crypto";
const KEY = crypto.createHash("sha256").update(process.env.CLIENT_SECRET).digest("hex");
app.post("/hooks/minecraft", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-DMS-Timestamp") ?? "";
const signature = req.get("X-DMS-Signature") ?? "";
const raw = req.body.toString("utf8");
const expected =
"sha256=" + crypto.createHmac("sha256", KEY).update(timestamp + "." + raw).digest("hex");
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
if (!ok || !fresh) return res.sendStatus(401);
const event = JSON.parse(raw); // { event, state, scopes, player }
// จับคู่ event.state กับผู้ใช้ของคุณ แล้วผูก event.player.uuid
res.sendStatus(204);
});# Python (Flask)
import hashlib, hmac, time
from flask import request, abort
KEY = hashlib.sha256(CLIENT_SECRET.encode()).hexdigest()
@app.post("/hooks/minecraft")
def hook():
ts = request.headers.get("X-DMS-Timestamp", "")
sig = request.headers.get("X-DMS-Signature", "")
raw = request.get_data(as_text=True)
expected = "sha256=" + hmac.new(KEY.encode(), f"{ts}.{raw}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected) or abs(time.time() - int(ts)) > 300:
abort(401)
event = request.get_json()
return "", 204การส่งและการลองใหม่
| หัวข้อ | รายละเอียด |
|---|---|
| ที่อยู่ปลายทาง | ต้องเป็น https:// และชื่อโฮสต์ต้องชี้ไปที่อยู่ สาธารณะ ที่อยู่ภายใน (loopback, 10.x, 172.16-31.x, 192.168.x, 169.254.x, 100.64-127.x, IPv6 ภายใน) ถูกปฏิเสธ ทุกครั้งที่ส่ง |
| การ redirect | ไม่ตาม redirect (ตอบ 3xx ถือว่าไม่สำเร็จ) |
| เวลารอ | 5 วินาทีต่อครั้ง |
| เกณฑ์สำเร็จ | ตอบ 2xx ถึงจะถือว่าได้รับแล้ว |
| ลองใหม่ | ส่งไม่สำเร็จ (ไม่ใช่ 2xx, หมดเวลา หรือเชื่อมต่อไม่ได้) ระบบลองซ้ำอีก 2 ครั้ง หลังจาก 1 วินาทีและ 4 วินาที รวมสูงสุด 3 ครั้ง แล้วเลิก ไม่มีคิวเก็บ |
| การไม่รอ | การส่งเกิดเบื้องหลัง ผู้เล่นไม่ต้องรอ webhook ของคุณ |
| การซ้ำ | อาจได้รับ webhook เดียวกันซ้ำได้ (เช่น ตอบช้าจนระบบลองใหม่) ให้ประมวลผลแบบ idempotent โดยใช้ state |
ข้อผิดพลาดที่ผู้เล่นอาจเห็นที่หน้า /authorize
หน้านี้ตรวจคำขอก่อนแสดงผล ถ้าไม่ถูกต้องจะแจ้งข้อผิดพลาดและไม่ redirect ผู้เล่นไปที่ไหน:
| ข้อความ (message จาก server) | สาเหตุ | วิธีแก้ |
|---|---|---|
Unknown application | ไม่มีแอปที่ client_id นี้ | ตรวจ client_id |
redirect_uri is not registered for this application | Redirect แต่ไม่ส่ง redirect_uri หรือไม่ตรงกับที่ลงทะเบียน | ลงทะเบียนที่อยู่ในหน้าแอป และส่งให้ตรงตัวอักษร |
Unknown scope: x | ขอ scope ที่ระบบไม่มี | ใช้ identify |
This application may not ask for: x | scope มีในระบบแต่แอปไม่ได้เปิด | เปิด scope ในหน้าแอป |
This application has no webhook set | โหมด Webhook แต่ยังไม่ได้ใส่ webhookUrl | ใส่ Webhook URL |
Validation failed | state ไม่ได้ส่งหรือสั้นกว่า 8 ตัว | ส่ง state แบบสุ่มยาวอย่างน้อย 8 ตัว |
เวลาและขีดจำกัดทั้งหมด
| รายการ | ค่า |
|---|---|
| คำขอ (จากกดดำเนินการต่อ จนผู้เล่นตอบใน Launcher) | 10 นาที |
| code ของ Redirect หลังผู้เล่นยืนยัน | 5 นาที ใช้ได้ครั้งเดียว |
| รหัส 4 ตัวที่ผู้เล่นกรอกใน Launcher | ผิดได้ไม่เกิน 5 ครั้ง ครบแล้วคำขอถูกยกเลิก |
| ตัวอักษรของรหัส 4 ตัว | A-Z และ 2-9 ตัดตัวที่สับสนออก (0 O 1 I L) ไม่สนตัวพิมพ์เล็กใหญ่ |
| การส่ง webhook | 5 วินาทีต่อครั้ง สูงสุด 3 ครั้ง |
ดูชนิดข้อมูลทั้งหมดที่ อ้างอิงชนิดข้อมูลและข้อผิดพลาด และ Instance API ที่ Instance API: ผู้เล่น

