กำลังโหลด...
กำลังโหลด...
학부모가 기존 See-KidLearn 계정으로 로그인하고, 표준 OAuth 2.0 + OpenID Connect로 프로필과 자녀 정보를 가져올 수 있습니다.
이 기술 문서는 OAuth 규격의 파라미터 이름과 일치시키기 위해 태국어와 영어를 함께 사용합니다.
See-KidLearn ทำหน้าที่เป็น Identity Provider ให้ระบบอื่นในเครือ (เช่น See-KidConsul) ผู้ปกครองกดปุ่ม “เข้าสู่ระบบด้วย See-KidLearn” ในแอปของคุณ ระบบจะพาไปหน้ายินยอมของเรา เมื่อกดอนุญาตแล้วจะพากลับมาพร้อม authorization code ที่คุณเอาไปแลกเป็น token ได้
ผู้ดูแลระบบ (super admin) สร้างให้ที่ /th/dashboard/admin → OAuth Clients โดยต้องแจ้ง 3 อย่าง: ชื่อแอป, redirect URI ทั้งหมด (production + localhost) และ scope ที่ต้องใช้
⚠️ client secret แสดงครั้งเดียวตอนสร้างเท่านั้น — ระบบเก็บไว้เป็น hash ถ้าทำหาย ต้องกด “ออก secret ใหม่” ซึ่งจะทำให้ secret เดิมใช้ไม่ได้ทันที
SEEKIDLEARN_ISSUER=https://see-kidlearn.com
SEEKIDLEARN_API=https://api.see-kidlearn.com/api/v1
SEEKIDLEARN_CLIENT_ID=skl_xxxxxxxxxxxxxxxxxxxxxxxx
SEEKIDLEARN_CLIENT_SECRET=sklsec_xxxxxxxxxxxxxxxxxxxxxxxx
SEEKIDLEARN_REDIRECT_URI=https://your-app.com/api/auth/seekidlearn/callback| Purpose | Method | URL |
|---|---|---|
| Discovery | GET | https://see-kidlearn.com/.well-known/openid-configuration |
| Authorization | GET | https://see-kidlearn.com/oauth/authorize |
| Token | POST | https://api.see-kidlearn.com/api/v1/oauth/token |
| User info | GET | https://api.see-kidlearn.com/api/v1/oauth/userinfo |
| Children | GET | https://api.see-kidlearn.com/api/v1/oauth/children |
| Introspect | POST | https://api.see-kidlearn.com/api/v1/oauth/introspect |
| Revoke | POST | https://api.see-kidlearn.com/api/v1/oauth/revoke |
| Register user (first-party) | POST | https://api.see-kidlearn.com/api/v1/oauth/register-user |
| Child sign-in (games) | POST | https://api.see-kidlearn.com/api/v1/oauth/token · grant child-password |
| Profile re-sync (first-party) | GET | https://api.see-kidlearn.com/api/v1/oauth/users/{sub} |
| Changed-user feed (first-party) | GET | https://api.see-kidlearn.com/api/v1/oauth/users?updated_since={iso} |
| Parent picture (public, <img>) | GET | https://api.see-kidlearn.com/api/v1/avatars/{sub}/{sig} |
| Default picture (placeholder) | GET | https://api.see-kidlearn.com/api/v1/avatars/default?seed={any} |
ทุก endpoint ที่ต้องยืนยันตัวตนของแอป รองรับทั้ง HTTP Basic (Authorization: Basic base64(client_id:client_secret)) และการส่ง client_id/client_secret มาใน body — แนะนำให้ใช้ Basic
ชื่อ claim ยึดตาม OIDC Core 1.0 §5.1 — เขียนโค้ดอ้างตามชื่อในคอลัมน์กลาง ไม่ใช่ชื่อ scope โดยเฉพาะเบอร์โทรที่ชื่อ phone_number ไม่ใช่ phone
| Scope | Claim ที่เพิ่มใน /oauth/userinfo | คำอธิบาย |
|---|---|---|
openid | (ไม่มี claim ของตัวเอง) | จำเป็นเสมอ — ทำให้ได้รับ id_token กลับมา |
profile | name · given_name · family_name · picture · picture_or_default · locale · role · created_at | ชื่อ, รูปโปรไฟล์, ภาษา, role |
email | email · email_verified | อีเมล และสถานะการยืนยันอีเมล |
phone | phone_number · phone_number_verified | เบอร์โทรศัพท์ และสถานะการยืนยัน |
subscription:read | subscription | แพ็กเกจและสถานะสมาชิก |
children:read | (ไม่มีใน userinfo) | ปลดล็อก GET /oauth/children |
child:profile | nickname · avatar_id · total_stars · parental_control … | โปรไฟล์ของ "เด็ก" — ออกให้เฉพาะตอนเด็ก login ด้วย Password นักเรียน |
sub, sub_type และ updated_at มีมาเสมอไม่ว่าจะขอ scope อะไร · ขอเฉพาะ scope ที่ใช้จริง — scope ที่อยู่นอก allow-list ของ client จะถูกตัดทิ้งเงียบ ๆ ไม่ทำให้คำขอล้มเหลว
💡 กติกาคือ ถ้าได้ scope นั้น field จะมาเสมอ ค่าที่ผู้ใช้ไม่ได้กรอกจะมาเป็น null ไม่ใช่หายไปทั้ง field ดังนั้น 'phone_number' in profile === false แปลว่า คุณไม่ได้รับ scope phone ส่วน profile.phone_number === null แปลว่า ผู้ใช้คนนี้ไม่มีเบอร์ในระบบ
phone_number | null ถ้าผู้ปกครองยังไม่เคยกรอกเบอร์ — หายไปทั้ง field ก็ต่อเมื่อไม่ได้ scope phone |
phone_number_verified | false ถ้ายังไม่ยืนยัน แต่ยัง "ส่งเบอร์มาให้" ตามปกติ · ไม่มีวันเป็น null |
email_verified | false ได้บ่อย — See-KidLearn ไม่ได้บังคับยืนยันอีเมลก่อนใช้งาน |
picture | null ถ้าผู้ปกครองไม่มีรูป (ไม่ได้อัปโหลด และไม่มีรูปจาก Google/Facebook/LINE) — ดูข้อ 4b |
picture_or_default | ไม่มีวันเป็น null — ได้ URL รูปเสมอ ถ้าไม่มีรูปจะได้รูป default แทน |
given_name / family_name | null ถ้าผู้ใช้ไม่ได้ตั้งค่าไว้ |
name | เป็น "" ถ้าไม่มีทั้ง display_name และชื่อ-นามสกุล |
locale | ไม่มีวันเป็น null — ค่า default คือ "th" |
subscription | null ถ้าผู้ใช้ยังไม่มี subscription record |
ประกาศ type ให้ทุก claim เป็น nullable ยกเว้น sub, sub_type, updated_at, locale และ picture_or_default และอย่าทำให้เบอร์/อีเมลที่ยังไม่ยืนยันเป็น hard error
ตัวอย่างจริงของ token ผู้ปกครองที่ได้ scope openid profile email phone subscription:read children:read — ค่าถูกปิดบัง แต่ ชื่อ field ครบทุกตัวที่มีโอกาสปรากฏ เอาไปทำ mock ได้เลย
GET https://api.see-kidlearn.com/api/v1/oauth/userinfo
Authorization: Bearer {access_token}
// grant = openid profile email phone subscription:read children:read
{
"sub": "660a1b2c3d4e5f6071829304",
"sub_type": "user",
"updated_at": "2025-09-02T04:30:11.421Z",
"name": "สมชาย ใจดี", // scope: profile
"given_name": "สมชาย",
"family_name": "ใจดี",
"picture": "https://api.see-kidlearn.com/api/v1/avatars/660a1b2c3d4e5f6071829304/k3Jd9QxPz2LmN8vT4wYb1A?v=3f9a1c7b2e", // null ถ้าไม่มีรูป
"picture_or_default": "https://api.see-kidlearn.com/api/v1/avatars/660a1b2c3d4e5f6071829304/k3Jd9QxPz2LmN8vT4wYb1A?v=3f9a1c7b2e", // ไม่มีวัน null
"locale": "th",
"role": "parent", // parent | admin | super_admin
"created_at": "2025-01-05T09:12:44.000Z",
"email": "somchai@example.com", // scope: email
"email_verified": true,
"phone_number": "0812345678", // scope: phone ← ชื่อตาม OIDC §5.1
"phone_number_verified": false, // ส่งมาถึงแม้ยังไม่ยืนยัน
"subscription": { // scope: subscription:read
"status": "trial", // trial|active|expired|cancelled|blocked
"package_slug": "basic",
"expires_at": "2025-10-01T00:00:00.000Z"
}
}
// children:read ไม่เพิ่ม field ที่นี่ — มันไปปลดล็อก GET /oauth/children แทนผู้ปกครองอัปโหลดรูปโปรไฟล์ได้เองที่ /dashboard/settings ระบบครอปเป็น สี่เหลี่ยมจัตุรัส 1:1 และเก็บเป็น JPEG 512×512 · ผู้ปกครองทุกคน — มีรูปหรือไม่มีก็ตาม — มี URL รูป public หนึ่งอัน:
https://api.see-kidlearn.com/api/v1/avatars/{sub}/{sig}?v={version}URL นี้มาใน claim picture_or_default (scope profile) ของ /oauth/userinfo, /oauth/users/{sub} และ id_token ให้ใช้ URL จาก claim เท่านั้น ห้ามประกอบเอง — sig เป็น HMAC ที่คำนวณได้เฉพาะฝั่ง See-KidLearn ถ้า sig ไม่ถูกต้องจะได้ 404
| กรณี | คำตอบ | X-Avatar-Source |
|---|---|---|
| ผู้ปกครองอัปโหลดรูปเอง | 200 · image/jpeg 512×512 | upload |
| ไม่ได้อัปโหลด แต่สมัครด้วย Google / Facebook / LINE | 302 → รูปจากผู้ให้บริการนั้น | social |
| ไม่มีรูปเลย หรือบัญชีถูกลบ/ระงับ | 200 · image/png 256×256 รูป default | default |
| sig ผิด / ไม่มี sig / sub ผิดรูปแบบ | 404 JSON | — |
picture = รูปจริง หรือ null ถ้าไม่มี (ใช้ตัดสินว่า “มีรูปไหม”) · picture_or_default = ไม่มีวัน null ใช้แสดงผลได้ทันที?v= เปลี่ยนทุกครั้งที่อัปโหลดรูปใหม่ — ถ้า v ตรงกับรูปปัจจุบันจะ cache ได้ 1 ปี (immutable) ถ้าไม่มี v หรือ v เก่า cache 5 นาที · รองรับ ETag → 304updated_at ขยับ ผู้ปกครองจึงโผล่ใน feed ของข้อ 8 — อ่านโปรไฟล์ใหม่แล้วเก็บ picture_or_default ตัวใหม่Cross-Origin-Resource-Policy: cross-origin ใส่ <img> ได้จากทุกโดเมน · เป็น JPEG/PNG/WebP เสมอ ไม่ใช่ SVG · ถ้าใช้ next/image ให้ตั้ง unoptimized หรือเพิ่ม host ของ API และ host รูป social ใน remotePatternsGET https://api.see-kidlearn.com/api/v1/avatars/default?seed={อะไรก็ได้}// 1) เอา URL จาก claim — ห้ามประกอบเอง (sig เป็น HMAC ที่ See-KidLearn คำนวณ)
const profile = await fetch(`${API}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then((r) => r.json());
const hasRealPhoto = profile.picture !== null; // มีรูปจริงไหม
const avatarSrc = profile.picture_or_default; // ใช้แสดงผล — ไม่มีวัน null
// 2) แสดงผล — รูปเป็นสี่เหลี่ยมจัตุรัส 1:1 ตัดเป็นวงกลมด้วย object-fit: cover
<img
src={avatarSrc}
alt={profile.name}
width={48}
height={48}
style={{ borderRadius: '9999px', objectFit: 'cover' }}
/>
// 3) รูปเปลี่ยน → updated_at ของผู้ใช้ขยับ → โผล่ใน GET /oauth/users?updated_since=
// อ่าน /oauth/users/{sub} ใหม่ แล้วเก็บ picture_or_default ตัวใหม่ (path เดิม, ?v= ใหม่)🔒 URL เดาไม่ได้ แต่เป็น public — ใครมี URL ก็เห็นรูป ให้ถือเหมือนเป็นตัวรูปเอง แสดงเฉพาะที่ผู้ปกครองคาดว่าจะเห็นรูปตัวเอง และลบสำเนาพร้อมบัญชี (ข้อ 9) · token ของ “เด็ก” ไม่เปลี่ยน: picture ยังเป็นอวตารของเด็ก
code_verifier, code_challenge, state, nonce แล้วเก็บใน httpOnly cookiehttps://see-kidlearn.com/oauth/authorizeredirect_uri ของคุณเหมือนกัน ฝั่งคุณไม่ต้องทำอะไรเพิ่มredirect_uri พร้อม ?code=…&state=…state แล้วแลก code เป็น token ที่ https://api.see-kidlearn.com/api/v1/oauth/tokenid_token แล้วเรียก /oauth/userinfo เพื่อสร้าง/อัปเดตบัญชีในระบบของคุณhttps://see-kidlearn.com/oauth/authorize
?response_type=code
&client_id=skl_9f2c8a1b4d6e0f3a7c5b2d81
&redirect_uri=https%3A%2F%2Fsee-kidconsul.com%2Fapi%2Fauth%2Fcallback
&scope=openid%20profile%20email%20children%3Aread
&state=Xk7dP2mQ...
&nonce=b91af0c3...
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256💡 authorization code มีอายุ 5 นาที และใช้ได้ครั้งเดียว — ถ้าถูกใช้ซ้ำ เราจะเพิกถอน token ทั้งหมดของผู้ใช้รายนั้นทันทีเพื่อความปลอดภัย
ตัวอย่างนี้ใช้ Next.js 15 route handlers — แนวคิดเดียวกันย้ายไป Express, Laravel หรือ framework อื่นได้ตรง ๆ
// lib/seekidlearn.ts
import crypto from 'node:crypto';
const base64url = (buf: Buffer) =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
export function createPkce() {
const code_verifier = base64url(crypto.randomBytes(64)); // 43–128 chars
const code_challenge = base64url(
crypto.createHash('sha256').update(code_verifier).digest(),
);
return {
code_verifier,
code_challenge,
state: base64url(crypto.randomBytes(32)),
nonce: base64url(crypto.randomBytes(32)),
};
}// app/api/auth/seekidlearn/login/route.ts
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { createPkce } from '@/lib/seekidlearn';
export async function GET() {
const { code_verifier, code_challenge, state, nonce } = createPkce();
// The verifier must survive the round-trip but never reach the browser's JS.
(await cookies()).set('skl_pkce', JSON.stringify({ code_verifier, state, nonce }), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 600, // 10 minutes
});
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.SEEKIDLEARN_CLIENT_ID!,
redirect_uri: process.env.SEEKIDLEARN_REDIRECT_URI!,
scope: 'openid profile email children:read',
state,
nonce,
code_challenge,
code_challenge_method: 'S256',
});
redirect(`${process.env.SEEKIDLEARN_ISSUER}/oauth/authorize?${params}`);
}// app/api/auth/seekidlearn/callback/route.ts
import crypto from 'node:crypto';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
const API = process.env.SEEKIDLEARN_API!;
const basicAuth = Buffer.from(
`${process.env.SEEKIDLEARN_CLIENT_ID}:${process.env.SEEKIDLEARN_CLIENT_SECRET}`,
).toString('base64');
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const error = url.searchParams.get('error');
if (error) {
// access_denied simply means the parent pressed "ไม่อนุญาต"
return NextResponse.redirect(new URL(`/login?error=${error}`, req.url));
}
const code = url.searchParams.get('code');
const returnedState = url.searchParams.get('state');
const jar = await cookies();
const raw = jar.get('skl_pkce')?.value;
if (!code || !raw) return NextResponse.redirect(new URL('/login?error=bad_request', req.url));
const { code_verifier, state, nonce } = JSON.parse(raw);
// Constant-time state comparison — this is the CSRF defence.
const a = Buffer.from(String(returnedState));
const b = Buffer.from(state);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return NextResponse.redirect(new URL('/login?error=state_mismatch', req.url));
}
// 1. Exchange the code for tokens (server-side only)
const tokenRes = await fetch(`${API}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.SEEKIDLEARN_REDIRECT_URI!,
code_verifier,
}),
});
const tokens = await tokenRes.json();
if (!tokenRes.ok) {
console.error('Token exchange failed:', tokens);
return NextResponse.redirect(new URL('/login?error=token_exchange', req.url));
}
// 2. Verify the id_token — HS256, signed with OUR client secret
const claims = jwt.verify(tokens.id_token, process.env.SEEKIDLEARN_CLIENT_SECRET!, {
algorithms: ['HS256'],
audience: process.env.SEEKIDLEARN_CLIENT_ID!,
issuer: process.env.SEEKIDLEARN_ISSUER!,
}) as jwt.JwtPayload;
if (claims.nonce !== nonce) {
return NextResponse.redirect(new URL('/login?error=nonce_mismatch', req.url));
}
// 3. Canonical profile
const profile = await fetch(`${API}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
}).then((r) => r.json());
// 4. Upsert on `sub` — never on email, which can change
await upsertUser({
seekidlearn_sub: profile.sub,
email: profile.email,
name: profile.name,
avatar_url: profile.picture_or_default, // never null — see 4b
// store encrypted, server-side only
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: new Date(Date.now() + tokens.expires_in * 1000),
});
// 5. Issue THIS app's own session, then drop the PKCE cookie
const res = NextResponse.redirect(new URL('/dashboard', req.url));
jar.delete('skl_pkce');
await createSession(res, profile.sub);
return res;
}refresh token หมุนเวียนทุกครั้ง: ทุกการ refresh จะได้ token ใหม่และตัวเก่าใช้ไม่ได้ทันที ถ้ามีการนำตัวเก่ามาใช้ซ้ำ เราถือว่า token รั่วและจะเพิกถอนทั้งชุด ผู้ใช้ต้องเข้าสู่ระบบใหม่ จึงต้องบันทึกคู่ token ใหม่ให้เรียบร้อยก่อนใช้งาน และอย่า refresh พร้อมกันหลายจุด
// Refresh tokens ROTATE: each refresh returns a new one and kills the old.
// Persist the new pair before using it, and never refresh concurrently.
export async function refreshAccessToken(refreshToken: string) {
const res = await fetch(`${API}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
});
const data = await res.json();
if (!res.ok) {
// invalid_grant → the token was replayed or expired. Sign the user out.
throw new Error(data.error_description || data.error);
}
return data; // { access_token, refresh_token, expires_in, ... }
}
// Logout — always returns 200, even for an unknown token
export async function revoke(token: string) {
await fetch(`${API}/oauth/revoke`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({ token }),
});
}⚠️ /oauth/userinfo คือ ภาพนิ่ง ณ วินาทีที่เรียก ถ้าคุณเรียกแค่ตอน callback ครั้งเดียว ข้อมูลฝั่งคุณจะค้างทันทีที่ผู้ใช้ไปแก้เบอร์/อีเมล/ชื่อที่ See-KidLearn — และถ้า session ฝั่งคุณอายุยาว เขาอาจไม่ login ใหม่อีกเลย อย่าพึ่งการ re-login เพื่อความสดของข้อมูล
เรามี 2 endpoint สำหรับงานนี้ ทั้งคู่ยืนยันตัวตนด้วย client credentials (HTTP Basic) ไม่ต้องใช้ access token ของผู้ใช้ และเปิดให้เฉพาะ client ที่เป็น confidential + first-party เท่านั้น — นอกนั้นได้ 403 unauthorized_client
GET https://api.see-kidlearn.com/api/v1/oauth/users/{sub} — อ่านโปรไฟล์ทีละคน คืน claim ชุดเดียวกับ /oauth/userinfo ตาม scope ที่ผู้ใช้คนนั้นอนุญาตให้ client คุณไว้จริง พร้อม status และ scope · ถ้าตอบ 404 no_grant แปลว่าไม่มี grant ที่ยังใช้ได้ (ไม่เคยเชื่อมต่อ / ยกเลิกแล้ว / refresh token หมดอายุ) ให้เลิก sync คนนั้นGET https://api.see-kidlearn.com/api/v1/oauth/users?updated_since={iso}&limit=100 — รายชื่อคนที่ข้อมูลเปลี่ยนตั้งแต่เวลาที่ระบุ คืนแค่ sub กับสถานะ ไม่มีข้อมูลส่วนบุคคล จึงเอาไปลง log ได้ · limit default 100 สูงสุด 200// 1) ถามว่าใครเปลี่ยนบ้าง — ตอบมาแค่ id + สถานะ ไม่มีข้อมูลส่วนบุคคล
const feed = await fetch(
`${API}/oauth/users?updated_since=${lastSyncedAt.toISOString()}&limit=100`,
{ headers: { Authorization: `Basic ${basicAuth}` } },
).then((r) => r.json());
// {
// updated_since: '2025-09-01T00:00:00.000Z',
// limit: 100, count: 2, has_more: false,
// next_updated_since: '2025-09-02T04:30:11.421Z',
// users: [
// { sub: '660a…', status: 'active', updated_at: '…', deleted_at: null, purge_at: null },
// { sub: '661b…', status: 'deleted', updated_at: '…',
// deleted_at: '2025-09-02T04:30:11.421Z', purge_at: '2025-10-02T04:30:11.421Z' },
// ],
// }
// 2) อ่านโปรไฟล์ล่าสุดเฉพาะคนที่ยัง active
for (const row of feed.users) {
if (row.status !== 'active') { await handleLifecycle(row); continue; }
const res = await fetch(`${API}/oauth/users/${row.sub}`, {
headers: { Authorization: `Basic ${basicAuth}` },
});
// 404 no_grant = ผู้ใช้ยกเลิกการเชื่อมต่อ/หมดอายุแล้ว → เลิก sync คนนี้
if (res.status === 404) { await disconnect(row.sub); continue; }
const profile = await res.json();
// claim ชุดเดียวกับ /oauth/userinfo + status + scope ที่ผู้ใช้อนุญาตไว้จริง
await updateLocalUser(row.sub, profile);
}
// 3) เลื่อน watermark — ถ้า has_more ให้วนต่อก่อนค่อยบันทึก
lastSyncedAt = new Date(feed.next_updated_since);
// ⚠️ updated_at ขยับทุกครั้งที่ user document ถูกเขียน ไม่ใช่เฉพาะ field ที่คุณสนใจ
// ถือว่า row = "ไปอ่านคนนี้ใหม่" เท่านั้น แล้ว diff กับของเดิมก่อนจะทำอะไรต่อlast_synced_at ไว้ (ครั้งแรกใช้เวลาที่เริ่มเปิดใช้งาน)updated_since=last_synced_atstatus: "active" → ไปอ่าน GET /oauth/users/{sub} แล้วอัปเดตสำเนาฝั่งคุณ · ที่เหลือดูข้อ 9next_updated_since เป็น watermark ใหม่ · ถ้า has_more เป็น true ให้วนเรียกต่อก่อนค่อยบันทึก⚠️ scope ใน response คือ scope ที่ “ผู้ใช้คนนั้น” อนุญาตไว้ ไม่ใช่ allow-list ของ client · ผู้ใช้ที่ authorize ครั้งล่าสุด ก่อน ที่ scope นั้นจะถูกเพิ่มให้ client จะไม่ได้ claim ของ scope นั้น และการ refresh ขยาย scope ไม่ได้ — มีทางเดียวคือให้เขาผ่าน /oauth/authorize ใหม่ · ดังนั้นถ้าเพิ่ง grant scope ใหม่ให้ client จะมีช่วงที่คนที่ login ใหม่ได้ claim นั้น แต่ session เก่าไม่ได้ และ sync ไม่ช่วย ให้อ่านค่า scope ทุกครั้งแทนที่จะเดา และอย่าถือว่าเป็น error ชั่วคราวที่ต้อง retry
ทั้งสอง endpoint ใช้โควตาร่วมกันที่ 600 request ต่อ 5 นาที ต่อ client ซึ่งมากกว่ารอบ sync ปกติมาก · ถ้าเจอ slow_down (429) ให้รอตาม Retry-After แล้วเริ่มต่อจาก watermark ที่บันทึกไว้ล่าสุด
💡 updated_at ขยับทุกครั้งที่ user document ถูกเขียน ไม่ใช่เฉพาะ field ที่คุณสนใจ feed นี้จึง แจ้งเกินดีกว่าแจ้งขาด — ให้ถือว่าแต่ละแถวแปลว่า “ไปอ่านคนนี้ใหม่” เท่านั้น ไม่ใช่หลักฐานว่า field ที่คุณสนใจเปลี่ยน ควร diff กับของเดิมก่อนจะทำอะไรต่อ ไม่อย่างนั้นจะส่งเมล “เบอร์ของคุณถูกเปลี่ยน” ให้คนที่ไม่ได้เปลี่ยนเบอร์
ผู้ปกครองลบบัญชี See-KidLearn เองได้ และแอดมินระงับบัญชีได้ — token ที่ออกไปแล้วจะยังไม่ล้มทันที เพราะยังไม่หมดอายุ คุณจึงต้องเช็ค “สถานะบัญชี” ไม่ใช่แค่ “สถานะ token”
active | ปกติ |
suspended | ถูกแอดมินระงับหรือปิดใช้งาน — ย้อนกลับได้ ให้บล็อกการเข้าใช้และซ่อนบัญชี แต่ยังเก็บข้อมูลไว้ |
deleted | ผู้ใช้ขอลบบัญชี เราทำ soft delete แล้วลบถาวรที่ purge_at (+30 วัน) — ใช้ช่วงนี้เคลียร์การจองที่ค้าง และลบ/ปิดข้อมูลฝั่งคุณให้เสร็จก่อนถึง purge_at |
purged | ลบถาวรไปแล้ว ไม่มีอะไรให้ sync อีก — ลบสำเนาฝั่งคุณทันที |
อ่านสถานะได้ 3 ที่:
GET /oauth/users/{sub} → status และถ้าถูกลบจะมี deleted_at + purge_atGET /oauth/users?updated_since= → ชุดเดียวกัน ของทุกคนที่เปลี่ยนในครั้งเดียวPOST /oauth/introspect → account_status, account_deleted_at, account_purge_at// GET https://api.see-kidlearn.com/api/v1/oauth/users/{sub} ของบัญชีที่ถูกลบ — ไม่มี claim ส่วนบุคคลกลับมาเลย
{
"sub": "661b…",
"status": "deleted",
"purged": false,
"scope": "openid profile email phone",
"updated_at": "2025-09-02T04:30:11.421Z",
"deleted_at": "2025-09-02T04:30:11.421Z",
"purge_at": "2025-10-02T04:30:11.421Z" // ← เส้นตายตาม PDPA ของฝั่งคุณ
}
// POST https://api.see-kidlearn.com/api/v1/oauth/introspect
{
"active": true, // ← นี่คือสถานะของ "token" (RFC 7662)
"scope": "openid profile email phone",
"client_id": "skl_…",
"token_type": "Bearer",
"sub": "660a…",
"sub_type": "user", // user | child | client
"child_sub": undefined, // มีค่าเมื่อ sub_type = 'child' (sub คือผู้ปกครอง)
"account_status": "active", // ← นี่คือสถานะของ "บัญชี" — ใช้ตัวนี้ตัดสินใจ
"account_deleted_at": undefined,
"account_purge_at": undefined,
"exp": 1735689600,
"iat": 1735686000,
"iss": "https://see-kidlearn.com"
}
// active: true พร้อม account_status: 'deleted' เกิดขึ้นได้ — token ยังไม่หมดอายุ
// แต่เจ้าของบัญชีลบไปแล้ว ดังนั้นอย่าตัดสินใจจาก active เพียงอย่างเดียว⚠️ ที่ /oauth/introspect ฟิลด์ active หมายถึง ตัว token ตามมาตรฐาน RFC 7662 ไม่ใช่บัญชี — เป็นไปได้ที่ active: true แต่ account_status: "deleted" ให้ตัดสินใจให้สิทธิ์จาก account_status เสมอ
🔒 บัญชีที่ถูกลบ/ระงับ จะ ไม่คืน claim ส่วนบุคคลเลย ที่ /oauth/users/{sub} และ /oauth/userinfo กับ /oauth/children จะตอบ 401 invalid_token — ให้ถือว่า 401 นี้แปลว่า “หยุด” ไม่ใช่ “ลองใหม่”
ต้องได้รับ scope children:read จากผู้ปกครองก่อน — endpoint นี้ไม่คืน game password หรือ PIN ของเด็กในทุกกรณี
const res = await fetch(`${API}/oauth/children`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
// 403 insufficient_scope if children:read was not granted
const { sub, children } = await res.json();
// {
// sub: '660a1b2c3d4e5f6071829304',
// children: [{
// id: '665f1a2b3c4d5e6f70819203',
// nickname: 'น้องไอซ์',
// avatar_id: 'bear',
// avatar_url: 'https://cdn.see-kidlearn.com/avatars/bear.png', // nullable
// date_of_birth: '2020-04-11T00:00:00.000Z', // nullable
// gender: 'female', // nullable
// grade_id: '6612aa…', // nullable
// total_stars: 120,
// streak_days: 3,
// last_played_at: '2025-09-01T11:02:33.000Z', // nullable
// created_at: '2025-01-05T09:20:00.000Z',
// }],
// }
// คืนเฉพาะลูกที่ยัง active และไม่มี game_password / pin ในทุกกรณีถ้าเว็บของคุณเป็นเกมที่ให้ เด็กเป็นคนล็อกอินเอง ไม่ใช่ผู้ปกครอง ใช้ grant พิเศษนี้ได้ โดยเด็กกรอก “Password นักเรียน” 5 หลัก (เช่น K7M2Q) ที่ผู้ปกครองดูและสุ่มใหม่ได้จากหน้าโปรไฟล์ลูกใน See-KidLearn
ผู้ดูแลระบบต้องติ๊ก “ให้นักเรียนเข้าสู่ระบบได้” (allow_child_login) และให้ scope child:profile กับ client ของคุณก่อน
// เด็ก login ด้วย Password นักเรียน 5 หลัก — ต้องรันบนเซิร์ฟเวอร์เท่านั้น
const res = await fetch(`${API}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: 'urn:see-kidlearn:params:oauth:grant-type:child-password',
game_password: gamePassword.toUpperCase(), // เช่น "K7M2Q"
scope: 'openid child:profile',
}),
});
// { access_token, refresh_token, expires_in, scope } — ไม่มี id_token
// userinfo ด้วย token นี้จะได้ "เด็ก" ไม่ใช่ผู้ปกครอง
const child = await fetch(`${API}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then((r) => r.json());
// {
// sub: '665f…', sub_type: 'child', parent_sub: '660a…',
// nickname: 'น้องไอซ์', avatar_id: 'bear',
// picture: 'https://cdn…/bear.png', // nullable
// date_of_birth: '2020-04-11T00:00:00.000Z', // nullable
// gender: 'female', // nullable
// grade_id: '6612aa…', // nullable
// total_stars: 120, total_score: 4820, streak_days: 3, locale: 'th',
// parental_control: {
// daily_time_limit_minutes: 60, allowed_start_time: '08:00',
// allowed_end_time: '20:00', used_minutes_today: 25, remaining_minutes: 35,
// },
// }
// ถ้าไม่ได้ scope child:profile จะได้แค่ { sub, sub_type, parent_sub }
// และไม่มี email / phone_number / subscription ของผู้ปกครองในทุกกรณี
// เกมต้องเคารพ parental_control เอง
if (child.parental_control.remaining_minutes <= 0) {
// หมดเวลาเล่นของวันนี้ — พาไปหน้า "พักก่อนนะ"
}🔒 token ที่ได้ผูกกับ เด็ก ไม่ใช่ผู้ปกครอง — อ่านอีเมล เบอร์โทร แพ็กเกจ หรือรายชื่อพี่น้องไม่ได้ (/oauth/children จะตอบ 403) และไม่มี id_token เพราะ subject ไม่ใช่ผู้ใช้แบบ OIDC
⚠️ Password นักเรียนมี 5 หลักจากตัวอักษร 32 ตัว (~33 ล้านแบบ) เราจึงจำกัดจำนวนครั้งที่ลองผิด ไว้เข้มกว่า grant อื่น — เจอ slow_down (429) ให้หยุดรอตาม Retry-Afterห้ามวน retry และ ห้ามเก็บ Password นักเรียนไว้ในระบบของคุณ
แอปที่บริษัทเป็นเจ้าของเอง (ติดธง first-party ในหน้าแอดมิน) สามารถมีฟอร์ม login และ register ของตัวเองได้ โดยไม่ต้อง redirect มาที่ See-KidLearn และเป็นเงื่อนไขของ endpoint sync ในข้อ 8 กับ 9 ด้วย
💡 เช็คว่า client ของคุณติดธงนี้หรือยังได้เอง โดยเรียก GET https://api.see-kidlearn.com/api/v1/oauth/client-info?client_id=…&redirect_uri=… (public) แล้วดูค่า data.is_first_party — ถ้าเป็น false ให้ติดต่อผู้ดูแลระบบ ไม่อย่างนั้น /oauth/users… จะตอบ 403 unauthorized_client ตลอด
⚠️ โหมดนี้ทำให้แอปปลายทาง เห็นรหัสผ่านของผู้ใช้ จึงเปิดให้เฉพาะแอปที่เราควบคุมเองเท่านั้น แอปของบุคคลที่สามต้องใช้ redirect flow ในข้อ 4 เสมอ
// Only for clients flagged "first-party" in the See-KidLearn admin.
// Both calls MUST run on your server — they carry the client secret.
// Login with a form you host yourself
await fetch(`${API}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: 'password',
username: email,
password,
scope: 'openid profile email',
}),
});
// Register a new See-KidLearn parent from your own form.
// Returns the same token payload, so the user lands signed in.
// 409 account_exists → send them to login instead.
await fetch(`${API}/oauth/register-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${basicAuth}`,
},
body: JSON.stringify({
email, password, first_name, last_name, phone, language: 'th',
}),
});ทุก error ตอบตามมาตรฐาน RFC 6749: { "error": "...", "error_description": "..." }
invalid_client | client_id / client_secret ผิด หรือ client ถูกปิดใช้งาน |
invalid_grant | code หรือ refresh token หมดอายุ/ถูกใช้แล้ว หรือ redirect_uri ไม่ตรง |
invalid_request | พารามิเตอร์ไม่ครบ หรือ client บังคับ PKCE แต่ไม่ได้ส่ง code_challenge |
unauthorized_client | client นี้ไม่ได้เปิด grant type ที่ขอ |
invalid_scope | scope ที่ขอไม่อยู่ใน allow-list ของ client |
insufficient_scope | token ไม่มี scope ที่ endpoint ต้องการ (HTTP 403) |
access_denied | ผู้ปกครองกดปฏิเสธที่หน้ายินยอม |
slow_down | ยิง /oauth/token ถี่เกินไป (HTTP 429) — ดู header Retry-After |
unauthorized_client (child) | client ยังไม่ได้เปิดสิทธิ์ allow_child_login ให้เด็ก login |
no_grant | GET /oauth/users/{sub} (404) — ผู้ใช้ไม่ได้เชื่อมต่อกับแอปนี้อยู่ (ยังไม่เคยอนุญาต / ยกเลิกแล้ว / หมดอายุ) → เลิก sync คนนี้ |
unauthorized_client (sync) | /oauth/users… เปิดให้เฉพาะ client ที่เป็น confidential + first-party (403) |
invalid_token (401 ที่ userinfo) | token หมดอายุ/ถูกเพิกถอน หรือ "บัญชีถูกระงับ/ถูกลบ" — แยกสองกรณีด้วย /oauth/introspect อย่าวน retry |
คัดลอกข้อความด้านล่างทั้งก้อนไปวางให้ AI ที่ดูแลโปรเจกต์ปลายทาง (Claude Code, Cursor, Copilot ฯลฯ) มันครอบคลุมทุกอย่างที่ต้องรู้ ตั้งแต่ env, flow, ตัวอย่าง request/response, ข้อควรระวังด้านความปลอดภัย ไปจนถึงรายการสิ่งที่ต้องส่งมอบ — อ่านจบแล้วลงมือทำได้ทันที
# TASK: Implement "Sign in with See-KidLearn" (OAuth 2.0 + OIDC) in this project
You are integrating this application with **See-KidLearn** as the identity provider,
so parents can sign in with their existing See-KidLearn account and this app can read
their profile and (optionally) their children's profiles.
## 1. Credentials (ask the operator for these — do not invent them)
| Env var | Example | Notes |
|---|---|---|
| SEEKIDLEARN_ISSUER | https://see-kidlearn.com | Web origin; hosts the consent screen |
| SEEKIDLEARN_API | https://api.see-kidlearn.com/api/v1 | API origin; hosts token/userinfo |
| SEEKIDLEARN_CLIENT_ID | skl_xxxxxxxx… | Public identifier |
| SEEKIDLEARN_CLIENT_SECRET | sklsec_xxxxxxxx… | SERVER ONLY. Never ship to the browser. |
| SEEKIDLEARN_REDIRECT_URI | https://your-app.com/api/auth/seekidlearn/callback | Must match the registered value byte for byte |
Discovery document: `GET {SEEKIDLEARN_ISSUER}/.well-known/openid-configuration`
## 2. Endpoints
| Purpose | Method + URL |
|---|---|
| Authorization (browser redirect) | `GET {ISSUER}/oauth/authorize` |
| Token | `POST {API}/oauth/token` |
| User info | `GET {API}/oauth/userinfo` |
| Children list | `GET {API}/oauth/children` (needs `children:read`) |
| Introspect | `POST {API}/oauth/introspect` |
| Revoke | `POST {API}/oauth/revoke` |
| Register a user (first-party only) | `POST {API}/oauth/register-user` |
| Child sign-in (games) | `POST {API}/oauth/token` with the child-password grant |
| Profile re-sync (first-party only) | `GET {API}/oauth/users/{sub}` |
| Changed-user feed (first-party only) | `GET {API}/oauth/users?updated_since={iso}` |
| Parent profile picture (public image, no auth) | `GET {API}/avatars/{sub}/{sig}` — take the URL from the `picture_or_default` claim, never build it (§3c) |
| Default picture (placeholder) | `GET {API}/avatars/default?seed={any}` |
## 3. Scopes → exact claim names
Claim names follow **OIDC Core 1.0 §5.1**. Code against these spellings, not the
scope names — in particular the phone claim is `phone_number`, **not** `phone`.
| Scope | Claims it adds to `/oauth/userinfo` |
|---|---|
| `openid` | (required for an `id_token`; adds no claim of its own) |
| `profile` | `name`, `given_name`, `family_name`, `picture`, `picture_or_default`, `locale`, `role`, `created_at` |
| `email` | `email`, `email_verified` |
| `phone` | `phone_number`, `phone_number_verified` |
| `subscription:read` | `subscription` (object, or `null`) |
| `children:read` | nothing in userinfo — unlocks `GET /oauth/children` |
| `child:profile` | a CHILD's own profile; only ever issued by the child-password grant |
`sub`, `sub_type` and `updated_at` are always present, whatever the scope.
Request only what you actually use. Anything outside the client's registered
allow-list is silently dropped.
### Which claims can be missing, and when
The rule is: **if the scope was granted, the key is always present.** A value the
parent never filled in comes back as `null` — never as an absent key. So
`"phone_number" in profile === false` means *you did not get the `phone` scope*,
while `profile.phone_number === null` means *this parent has no phone on file*.
| Claim | When it is `null` / absent |
|---|---|
| `phone_number` | `null` if the parent never entered a phone. **Absent** only if `phone` was not granted. |
| `phone_number_verified` | `false` when unverified — the number is **still returned**. Never null. |
| `email_verified` | `false` until the parent clicks the verification link. See-KidLearn does not force verification, so plenty of live accounts sit at `false`. |
| `picture` | `null` if the parent has no photo — never uploaded one and has no Google/Facebook/LINE picture. See §3c. |
| `picture_or_default` | **never null** — always an image URL (the photo, or a default picture). See §3c. |
| `given_name`, `family_name` | `null` if unset |
| `name` | `""` if the parent has neither a display name nor a first/last name |
| `locale` | never null — defaults to `"th"` |
| `subscription` | `null` if the parent has no subscription record at all |
| everything else | absent if its scope was not granted |
Treat every claim except `sub`, `sub_type`, `updated_at`, `locale` and
`picture_or_default` as nullable in your types. Do not make an unverified phone or
email a hard failure.
## 3b. Full `/oauth/userinfo` response (parent token)
Granted `openid profile email phone subscription:read children:read`. Values are
masked; **every field name that can appear is shown**:
```http
GET {API}/oauth/userinfo
Authorization: Bearer {access_token}
```
```json
{
"sub": "660a1b2c3d4e5f6071829304",
"sub_type": "user",
"updated_at": "2025-09-02T04:30:11.421Z",
"name": "สมชาย ใจดี",
"given_name": "สมชาย",
"family_name": "ใจดี",
"picture": "https://api.see-kidlearn.com/api/v1/avatars/660a1b2c3d4e5f6071829304/k3Jd9QxPz2LmN8vT4wYb1A?v=3f9a1c7b2e",
"picture_or_default": "https://api.see-kidlearn.com/api/v1/avatars/660a1b2c3d4e5f6071829304/k3Jd9QxPz2LmN8vT4wYb1A?v=3f9a1c7b2e",
"locale": "th",
"role": "parent",
"created_at": "2025-01-05T09:12:44.000Z",
"email": "somchai@example.com",
"email_verified": true,
"phone_number": "0812345678",
"phone_number_verified": false,
"subscription": {
"status": "trial",
"package_slug": "basic",
"expires_at": "2025-10-01T00:00:00.000Z"
}
}
```
`children:read` adds nothing here — it unlocks `GET /oauth/children` (§5).
`role` is one of `parent` | `admin` | `super_admin`.
`subscription.status` is one of `trial` | `active` | `expired` | `cancelled` | `blocked`.
## 3c. Parent profile picture
Parents can upload a profile photo on see-kidlearn (Settings → Profile). It is
cropped to a square (1:1) and stored as a 512×512 JPEG. Every parent — with a
photo or without — has ONE public picture URL:
```
{API}/avatars/{sub}/{sig}?v={version}
```
It arrives as the `picture_or_default` claim (scope `profile`) in
`/oauth/userinfo`, `GET /oauth/users/{sub}` and the `id_token`. **Take the URL from
the claim — never build it yourself.** `{sig}` is an HMAC only see-kidlearn can
compute, so a guessed or hand-built URL answers `404`.
The two claims:
| Claim | Value | Use it to |
|---|---|---|
| `picture` | The parent's real picture URL, or `null` when they have none | decide "does this parent have a photo?" |
| `picture_or_default` | Never null. The URL above: the photo, or a default picture | display — put it straight into `<img src>` |
What the URL returns — no auth header, works from any origin:
| Situation | Response | `X-Avatar-Source` header |
|---|---|---|
| Parent uploaded a photo | `200`, `image/jpeg` (png/webp possible), 512×512 | `upload` |
| No upload, but they signed up with Google / Facebook / LINE | `302` redirect to that picture | `social` |
| Neither — or the account is deleted / suspended | `200`, `image/png` 256×256: white head-and-shoulders silhouette on a colour picked from the `sub` | `default` |
| `sig` wrong or missing, `sub` malformed | `404` JSON | — |
Freshness and caching:
- `?v=` changes every time the parent uploads a new photo. With the current `v`
the image is sent `Cache-Control: public, max-age=31536000, immutable`; without
`v` (or with an old one) `max-age=300`. `ETag` / `If-None-Match` → `304` works.
- Uploading or removing a photo bumps the user's `updated_at`, so the changed-user
feed (§4b) reports the parent. Re-read the profile and store the new
`picture_or_default` — the path stays the same, only `v` moves.
- Prefer linking to the URL over downloading and re-hosting the image. If you
must keep a copy, fetch it server-side and key your copy on `v`.
- Served with `Cross-Origin-Resource-Policy: cross-origin`, so `<img>` works on any
site. Reading the bytes with `fetch()` from browser JS is a CORS request — do that
server-side instead.
Rendering: the image is square, so show it with `object-fit: cover` in a circle
or rounded square. Every response is JPEG/PNG/WebP, never SVG. Simplest is a
plain `<img>`. With Next.js `next/image`, either set `unoptimized`, or add
`api.see-kidlearn.com` (pathname `/api/v1/avatars/**`) to `images.remotePatterns`
**and** the social hosts (`lh3.googleusercontent.com`,
`profile.line-scdn.net`, `platform-lookaside.fbsbx.com`) — a social picture
reaches you through a 302 to one of those.
A default picture on its own, for a placeholder before anyone signs in:
`GET {API}/avatars/default?seed={anything}` (same style, colour picked from `seed`).
Privacy: the URL is unguessable but public — anyone holding it can see the photo.
Treat it like the photo itself: show it only where the parent would expect their
photo, and delete your copy with the rest of the account (§4c).
A child token is unaffected: its `picture` is still the child's avatar (§6).
## 4. Flow to implement — Authorization Code + PKCE (S256)
### Step A — start login (`GET /api/auth/seekidlearn/login`)
1. `code_verifier` = 64 random bytes, base64url, no padding (43–128 chars).
2. `code_challenge` = base64url(SHA-256(code_verifier)), no padding.
3. `state` = 32 random bytes, base64url. `nonce` = 32 random bytes, base64url.
4. Store `code_verifier`, `state`, `nonce` in an **httpOnly, Secure, SameSite=Lax**
cookie (or server session) with a 10-minute TTL.
5. Redirect the browser to:
```
{ISSUER}/oauth/authorize
?response_type=code
&client_id={CLIENT_ID}
&redirect_uri={REDIRECT_URI} (URL-encoded)
&scope=openid%20profile%20email
&state={state}
&nonce={nonce}
&code_challenge={code_challenge}
&code_challenge_method=S256
```
At the consent screen the parent may sign in with email/password **or with
Google, Facebook or LINE** — whichever they already use on see-kidlearn. Either
way they come back to your `redirect_uri` with the same `?code=`, so there is
nothing extra to implement for social sign-in.
### Step B — callback (`GET /api/auth/seekidlearn/callback`)
1. If the query contains `error`, render that error — do not continue.
`error=access_denied` simply means the parent pressed "ไม่อนุญาต".
2. Compare the returned `state` with the stored one using a constant-time
comparison. Mismatch → abort (CSRF).
3. Exchange the code (server-side, never from the browser):
```http
POST {API}/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64({CLIENT_ID}:{CLIENT_SECRET})
grant_type=authorization_code
&code={code}
&redirect_uri={REDIRECT_URI}
&code_verifier={code_verifier}
```
Response:
```json
{
"access_token": "…", // opaque, ~1 hour
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "…", // opaque, ~30 days, single-use (rotates)
"scope": "openid profile email",
"id_token": "eyJ…" // JWT, HS256, signed with YOUR client secret
}
```
4. Verify `id_token` with HS256 using `SEEKIDLEARN_CLIENT_SECRET` and check
`iss === SEEKIDLEARN_ISSUER`, `aud === CLIENT_ID`, `exp` in the future, and
`nonce` equal to the stored nonce.
5. `GET {API}/oauth/userinfo` with `Authorization: Bearer {access_token}` to get
the canonical profile. Exact claim names are in §3; the full response is §3b.
6. Upsert a local user keyed on `sub` (the See-KidLearn user id). **Key on `sub`,
never on email** — an email can change. Store the returned `updated_at` too;
the sync in §4b uses it.
7. Create this app's own session (its own cookie/JWT). Store the See-KidLearn
`access_token` / `refresh_token` encrypted, server-side only.
8. Delete the PKCE cookie and redirect the user into the app.
### Step C — refresh
```http
POST {API}/oauth/token
Authorization: Basic base64({CLIENT_ID}:{CLIENT_SECRET})
grant_type=refresh_token&refresh_token={refresh_token}
```
Refresh tokens **rotate**: every refresh returns a new one and invalidates the
old. Persist the new pair atomically. If you replay an old refresh token the
provider revokes the whole family and the user must sign in again — so never
refresh concurrently from two places.
### Step D — logout
`POST {API}/oauth/revoke` with `token={refresh_token}` and Basic auth, then clear
this app's session. It always returns 200.
## 4b. Keeping the local copy fresh (first-party clients)
`/oauth/userinfo` is a snapshot at the moment you call it. If you only call it in
the callback, your copy of the parent's phone, email and name goes stale the
moment they edit it on see-kidlearn — and if your own session is long-lived, they
may never sign in again to refresh it. **Do not rely on re-login for freshness.**
Two client-authenticated endpoints exist for this. Both use HTTP Basic with the
client credentials — no user access token — and both are restricted to
**confidential first-party** clients; anyone else gets `403 unauthorized_client`.
Check that your client actually carries the flag before building against these:
`GET {API}/oauth/client-info?client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}` is
public and returns `data.is_first_party`. If it is `false`, ask the See-KidLearn
operator to tick it — no amount of retrying will get past the 403.
### Re-read one profile
```http
GET {API}/oauth/users/{sub}
Authorization: Basic base64({CLIENT_ID}:{CLIENT_SECRET})
```
Returns exactly the `/oauth/userinfo` claim set for the scopes **that user has
currently granted your client**, plus `status` and the `scope` string so you can
see what you were allowed to read:
```json
{
"sub": "660a1b2c3d4e5f6071829304",
"sub_type": "user",
"updated_at": "2025-09-02T04:30:11.421Z",
"name": "สมชาย ใจดี",
"given_name": "สมชาย",
"family_name": "ใจดี",
"picture": "https://api.see-kidlearn.com/api/v1/avatars/660a1b2c3d4e5f6071829304/k3Jd9QxPz2LmN8vT4wYb1A?v=3f9a1c7b2e",
"picture_or_default": "https://api.see-kidlearn.com/api/v1/avatars/660a1b2c3d4e5f6071829304/k3Jd9QxPz2LmN8vT4wYb1A?v=3f9a1c7b2e",
"locale": "th",
"role": "parent",
"created_at": "2025-01-05T09:12:44.000Z",
"email": "somchai@example.com",
"email_verified": true,
"phone_number": "0812345678",
"phone_number_verified": false,
"status": "active",
"scope": "openid profile email phone"
}
```
`404 no_grant` means there is no live grant: the user never connected, revoked
your app, or their refresh token expired. Stop syncing that user.
**The `scope` in the response is that USER's grant, not your client's allow-list.**
A user who last authorized before a scope was added to your client gets a profile
without it, and refreshing cannot widen a token's scope — only a fresh trip through
`/oauth/authorize` can. So if `phone` was granted to your client recently, expect a
period where new sign-ins carry it and older sessions do not, and no amount of
syncing will backfill them.
Detect it by reading `scope` on every sync response rather than assuming it. If a
scope you need is missing for a user, the only fix is to send them through the
authorization flow again — decide deliberately whether that is worth prompting for,
and never treat it as a transient error to retry.
### The changed-user feed
Polling every user one by one does not scale. Ask for the ones that changed:
```http
GET {API}/oauth/users?updated_since=2025-09-01T00:00:00.000Z&limit=100
Authorization: Basic base64({CLIENT_ID}:{CLIENT_SECRET})
```
```json
{
"updated_since": "2025-09-01T00:00:00.000Z",
"limit": 100,
"count": 2,
"has_more": false,
"next_updated_since": "2025-09-02T04:30:11.421Z",
"users": [
{ "sub": "660a…", "status": "active", "updated_at": "2025-09-01T22:10:02.000Z",
"deleted_at": null, "purge_at": null },
{ "sub": "661b…", "status": "deleted", "updated_at": "2025-09-02T04:30:11.421Z",
"deleted_at": "2025-09-02T04:30:11.421Z", "purge_at": "2025-10-02T04:30:11.421Z" }
]
}
```
Only `sub` + lifecycle come back — no personal data — so this response is safe to
log. `limit` defaults to 100 and is capped at 200.
How to run the sync:
1. Store a `last_synced_at` watermark. Start it at the time you go live.
2. Call the feed with `updated_since=last_synced_at`.
3. For each row: `status: "active"` → `GET /oauth/users/{sub}` and update your copy.
Anything else → apply §6.
4. Save `next_updated_since` as the new watermark. While `has_more` is true, call
again with `next_updated_since` before saving it.
5. Every 15–60 minutes is plenty. There is no push/webhook channel today.
Both endpoints share a budget of 600 requests per 5 minutes per client. That is far
more than a normal sync needs; on `slow_down` (429) honour `Retry-After` and resume
from the watermark you last saved.
`updated_at` is bumped by **any** write to the user document, not only the fields
you care about, so the feed over-reports rather than under-reports. Treat a row as
"re-read this one", never as proof that a specific field changed. Diff against your
own copy before acting — otherwise you will send "your phone number changed" mail
to people whose phone number did not change.
## 4c. Deleted and suspended accounts
A parent can delete their See-KidLearn account, or an admin can suspend it. Either
way **your tokens do not fail immediately** — an already-issued access token stays
within its lifetime — so you must check the account state, not just the token.
Three places report it:
| Where | What you get |
|---|---|
| `GET /oauth/users/{sub}` | `status`, and for a deletion `deleted_at` + `purge_at` |
| `GET /oauth/users?updated_since=` | the same, for every changed user at once |
| `POST /oauth/introspect` | `account_status`, `account_deleted_at`, `account_purge_at` |
```json
// GET /oauth/users/{sub} for a deleted account — NO personal claims are returned
{
"sub": "661b…",
"status": "deleted",
"purged": false,
"scope": "openid profile email phone",
"updated_at": "2025-09-02T04:30:11.421Z",
"deleted_at": "2025-09-02T04:30:11.421Z",
"purge_at": "2025-10-02T04:30:11.421Z"
}
```
`status` / `account_status` values:
- `active` — normal.
- `suspended` — blocked or deactivated by an admin. Reversible: block sign-in and
hide the account, but keep the data; it may come back.
- `deleted` — the parent asked for deletion. See-KidLearn soft-deletes and purges
for good at `purge_at` (30 days later). **That window is your PDPA deadline:**
cancel or hand off their pending bookings, then delete or anonymise your copy by
`purge_at` at the latest.
- `purged` — the user document is gone. Nothing left to reconcile; delete your copy
now. (`GET /oauth/users/{sub}` answers with `"status": "deleted", "purged": true`.)
On `POST /oauth/introspect`, note that **`active` describes the TOKEN, not the
account** (RFC 7662). A token can be `"active": true` while `account_status` is
`"deleted"`. Gate access on `account_status`:
```json
{
"active": true,
"scope": "openid profile email phone",
"client_id": "skl_…",
"token_type": "Bearer",
"sub": "660a…",
"sub_type": "user",
"account_status": "active",
"exp": 1735689600,
"iat": 1735686000,
"iss": "https://see-kidlearn.com"
}
```
For a child token `sub` is the owning **parent** and the player is `child_sub`,
with `sub_type: "child"`. `account_status` then describes the parent account.
Meanwhile `/oauth/userinfo` and `/oauth/children` answer `401 invalid_token` for a
suspended or deleted account, so treat that 401 as "stop", not as "retry".
## 5. Reading children (optional)
```http
GET {API}/oauth/children
Authorization: Bearer {access_token}
```
Full response — every field name that can appear:
```json
{
"sub": "660a1b2c3d4e5f6071829304",
"children": [
{
"id": "665f1a2b3c4d5e6f70819203",
"nickname": "น้องไอซ์",
"avatar_id": "bear",
"avatar_url": "https://cdn.see-kidlearn.com/avatars/bear.png",
"date_of_birth": "2020-04-11T00:00:00.000Z",
"gender": "female",
"grade_id": "6612aa…",
"total_stars": 120,
"streak_days": 3,
"last_played_at": "2025-09-01T11:02:33.000Z",
"created_at": "2025-01-05T09:20:00.000Z"
}
]
}
```
Nullable here: `avatar_url`, `date_of_birth`, `gender`, `grade_id`,
`last_played_at` — all of them are optional in a child profile. Only active
children are listed, and `game_password` / `pin` are never returned.
Returns 403 `insufficient_scope` if `children:read` was not granted. Children data
concerns minors: store the minimum you need, never expose it to other users, and
delete it when the parent disconnects.
## 6. Child sign-in for games (only if the client has `allow_child_login`)
For a kids' game the player is the CHILD, not the parent. Every child profile in
see-kidlearn has a 5-character game password (e.g. `K7M2Q`) the parent can see
and regenerate in their dashboard. Exchange it for a child-scoped token:
```http
POST {API}/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64({CLIENT_ID}:{CLIENT_SECRET})
grant_type=urn:see-kidlearn:params:oauth:grant-type:child-password
&game_password=K7M2Q
&scope=openid child:profile
```
(`grant_type=child_password` is accepted as a shorter alias.)
Returns the normal token payload — `access_token`, `refresh_token`, `expires_in`
— but **no `id_token`**, because the subject is a child, not an OIDC end user.
`GET {API}/oauth/userinfo` with that token returns the child, not the parent:
```json
{
"sub": "665f1a2b3c4d5e6f70819203",
"sub_type": "child",
"parent_sub": "660a1b2c3d4e5f6071829304",
"nickname": "น้องไอซ์",
"avatar_id": "bear",
"picture": "https://cdn.see-kidlearn.com/avatars/bear.png",
"date_of_birth": "2020-04-11T00:00:00.000Z",
"gender": "female",
"grade_id": "6612aa…",
"total_stars": 120,
"total_score": 4820,
"streak_days": 3,
"locale": "th",
"parental_control": {
"daily_time_limit_minutes": 60,
"allowed_start_time": "08:00",
"allowed_end_time": "20:00",
"used_minutes_today": 25,
"remaining_minutes": 35
}
}
```
Nullable: `picture`, `date_of_birth`, `gender`, `grade_id`. Without the
`child:profile` scope the same call returns only
`{ "sub", "sub_type": "child", "parent_sub" }` — identity and nothing else.
There is no `email`, `phone_number` or `subscription` on a child token, ever.
Rules for this grant:
- The client must be confidential and flagged `allow_child_login`; otherwise the
provider answers `unauthorized_client`. The call is server-side only.
- The token carries **only** `openid` and `child:profile`. It cannot read the
parent's email, phone, subscription or the sibling list — `/oauth/children`
returns 403 for it.
- **Honour `parental_control`**: refuse to start play outside
`allowed_start_time`–`allowed_end_time`, and stop at `remaining_minutes` = 0.
Re-read `/oauth/userinfo` periodically; the counters are live.
- Child sign-in is rate limited harder than other grants (a 5-char password is
brute-forceable). On `slow_down` (429) back off — do not retry in a loop.
- Never store the game password. Keep the tokens server-side, key the game
session on `sub`.
## 7. First-party mode (only if the operator says this client is first-party)
A first-party client may host its own login/register form instead of redirecting:
```http
POST {API}/oauth/token
Authorization: Basic base64({CLIENT_ID}:{CLIENT_SECRET})
grant_type=password&username={email}&password={password}&scope=openid profile email
```
```http
POST {API}/oauth/register-user
Authorization: Basic base64({CLIENT_ID}:{CLIENT_SECRET})
Content-Type: application/json
{ "email": "...", "password": "...", "first_name": "...", "last_name": "...",
"phone": "0812345678", "language": "th" }
```
`register-user` returns the same token payload as the token endpoint, so the new
parent lands signed in. 409 `account_exists` means "send them to login instead".
Both calls MUST be made from your server. If the client is not flagged
first-party the provider answers 400/403 `unauthorized_client` — use the redirect
flow instead.
## 8. Errors
Errors follow RFC 6749: `{ "error": "...", "error_description": "..." }`.
| error | Meaning / fix |
|---|---|
| invalid_client | Wrong client_id/secret, or client disabled |
| invalid_grant | Code or refresh token expired, already used, or redirect_uri mismatch |
| invalid_request | Missing parameter, or PKCE required but no code_challenge |
| unauthorized_client | This grant type is not enabled for the client |
| invalid_scope | Requested scope not in the client's allow-list |
| insufficient_scope | Endpoint needs a scope the token lacks (403) |
| slow_down | Token endpoint rate limit (429); honour `Retry-After` |
| invalid_grant (child) | Wrong game password, or the parent account is suspended |
| no_grant | `GET /oauth/users/{sub}` (404): no live grant — never connected, revoked, or expired. Stop syncing that user. |
| unauthorized_client (sync) | `/oauth/users…` on a client that is not confidential + first-party (403) |
| invalid_token (401 on userinfo) | Token expired or revoked, **or** the account is suspended/deleted. Do not retry blindly — introspect to tell the two apart. |
## 9. Hard requirements
- The client secret, the token exchange, and the refresh call are **server-side only**.
- `redirect_uri` must match the registered value exactly — no trailing slash drift.
- Always send PKCE S256; always verify `state`.
- Store See-KidLearn tokens encrypted at rest; never in `localStorage`.
- Key local accounts on `sub`.
- The access token is opaque — do not try to decode it. Only `id_token` is a JWT.
- On `invalid_grant` during refresh, clear the local session and restart the login flow.
- A child token is not a parent token: check `sub_type` before assuming which one you hold.
- Do not treat the callback's `/oauth/userinfo` as permanently correct. Re-sync on a
schedule (§4b) — a long-lived session means the user may never sign in again.
- Type every claim as nullable except `sub`, `sub_type`, `updated_at`, `locale` and
`picture_or_default`. Read the phone as `phone_number`, not `phone`.
- Display the parent's picture from `picture_or_default` exactly as given — never
construct an avatar URL (§3c).
- Gate access on `account_status`, not on introspect's `active` (§4c), and honour
`purge_at` on a deleted account.
## 10. Deliverables
1. `GET /api/auth/seekidlearn/login` — builds PKCE + state and redirects.
2. `GET /api/auth/seekidlearn/callback` — validates, exchanges, upserts, creates a session.
3. A token-refresh helper used by every outbound call, handling rotation.
4. `POST /api/auth/logout` — revokes the refresh token and clears the session.
5. A "Sign in with See-KidLearn" button on the login page.
6. `.env.example` with the five variables above, and a README section covering setup.
7. A scheduled profile sync (first-party clients): reads
`GET /oauth/users?updated_since=`, re-reads each changed `active` user via
`GET /oauth/users/{sub}`, and persists the `last_synced_at` watermark.
8. Account-lifecycle handling: `suspended` → block sign-in and hide the account;
`deleted` → cancel or hand off pending work and erase your copy by `purge_at`;
`purged` / `no_grant` → erase now and stop syncing.
9. The parent's picture wherever the app shows the signed-in parent (header,
profile, booking lists): `<img src={picture_or_default}>`, square image shown
round with `object-fit: cover`, refreshed by the profile sync.
10. Tests for: PKCE generation, state mismatch rejection, id_token verification,
refresh rotation, the sync watermark advancing correctly across `has_more`
pages, and each account status driving the right local action.
Use the language, framework and conventions already present in this repository.
Do not add an OAuth library unless the repo already depends on one.อย่าลืมแทนค่า SEEKIDLEARN_CLIENT_ID / SEEKIDLEARN_CLIENT_SECRET ด้วยค่าจริงจากหน้าแอดมิน และ อย่าวาง client secret ลงในแชตของ AI ที่ไม่ได้อยู่ในเครื่องคุณ
문의하기 페이지에서 앱 이름과 발급받은 client id를 알려주시면 설정을 확인해 드립니다.