เธเธณเธฅเธฑเธเนเธซเธฅเธ...
เธเธณเธฅเธฑเธเนเธซเธฅเธ...
เปเบซเปเบเบนเปเบเบปเบเบเบญเบเปเบเบปเปเบฒเบชเบนเปเบฅเบฐเบเบปเบเบเปเบงเบเบเบฑเบเบเบต 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 เบเบตเปเปเบเปเบฎเบฑเบ เบเบตเบกเบเบฒเบเบเบฐเบเปเบงเบเบเบงเบเบชเบญเบเบเบฒเบเบเบฑเปเบเบเปเบฒเปเบซเป