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