กำลังโหลด...
กำลังโหลด...
保護者が既存の 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 |
ทุก endpoint ที่ต้องยืนยันตัวตนของแอป รองรับทั้ง HTTP Basic (Authorization: Basic base64(client_id:client_secret)) และการส่ง client_id/client_secret มาใน body — แนะนำให้ใช้ Basic
openid | จำเป็นเสมอ — ทำให้ได้รับ id_token กลับมา |
profile | ชื่อ, รูปโปรไฟล์, ภาษา, role |
email | อีเมล และสถานะการยืนยันอีเมล |
phone | เบอร์โทรศัพท์ และสถานะการยืนยัน |
children:read | รายชื่อโปรไฟล์บุตรหลานของผู้ปกครอง |
subscription:read | แพ็กเกจและสถานะสมาชิก |
child:profile | โปรไฟล์ของ "เด็ก" — ออกให้เฉพาะตอนเด็ก login ด้วย Password นักเรียน |
ขอเฉพาะ scope ที่ใช้จริง — scope ที่อยู่นอก allow-list ของ client จะถูกตัดทิ้งเงียบ ๆ ไม่ทำให้คำขอล้มเหลว
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,
// 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 }),
});
}ต้องได้รับ 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();
// children: [{ id, nickname, avatar_url, date_of_birth, gender,
// grade_id, total_stars, streak_days, last_played_at }]ถ้าเว็บของคุณเป็นเกมที่ให้ เด็กเป็นคนล็อกอินเอง ไม่ใช่ผู้ปกครอง ใช้ 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', total_stars: 120, streak_days: 3,
// parental_control: {
// daily_time_limit_minutes: 60, allowed_start_time: '08:00',
// allowed_end_time: '20:00', used_minutes_today: 25, remaining_minutes: 35,
// },
// }
// เกมต้องเคารพ 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
⚠️ โหมดนี้ทำให้แอปปลายทาง เห็นรหัสผ่านของผู้ใช้ จึงเปิดให้เฉพาะแอปที่เราควบคุมเองเท่านั้น แอปของบุคคลที่สามต้องใช้ 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 |
คัดลอกข้อความด้านล่างทั้งก้อนไปวางให้ 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 |
## 3. Scopes
- `openid` — required; returns an `id_token`
- `profile` — name, avatar, locale, role
- `email` — email + verification flag
- `phone` — phone + verification flag
- `children:read` — the parent's children profiles
- `subscription:read` — package and subscription status
- `child:profile` — a CHILD's own profile; only ever issued by the child-password grant below
Request only what you actually use. Anything outside the client's registered
allow-list is silently dropped.
## 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.
6. Upsert a local user keyed on `sub` (the See-KidLearn user id). **Key on `sub`,
never on email** — an email can change.
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.
## 5. Reading children (optional)
```http
GET {API}/oauth/children
Authorization: Bearer {access_token}
```
```json
{
"sub": "…",
"children": [
{ "id": "…", "nickname": "น้องไอซ์", "date_of_birth": "2020-04-11T00:00:00.000Z",
"gender": "female", "grade_id": "…", "total_stars": 120, "streak_days": 3 }
]
}
```
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": "665f…",
"sub_type": "child",
"parent_sub": "660a…",
"nickname": "น้องไอซ์",
"avatar_id": "bear",
"total_stars": 120,
"streak_days": 3,
"parental_control": {
"daily_time_limit_minutes": 60,
"allowed_start_time": "08:00",
"allowed_end_time": "20:00",
"used_minutes_today": 25,
"remaining_minutes": 35
}
}
```
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 |
## 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.
## 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. Tests for: PKCE generation, state mismatch rejection, id_token verification,
and refresh rotation.
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 を添えて「お問い合わせ」ページからご連絡ください。設定を確認いたします。