โค้ดด้านล่างคือชุดเดียวกับที่รันบนโดเมนทดสอบ — คัดลอก ใส่ .env แล้วเรียกหลังตรวจรหัสแอป
SSO Horizon · https://sso.doae.go.th
เลือกเส้นทางsomchai หรือ wanee รหัส password123แล้วเข้าบัญชี SSO ของกรมตัวเองclient_id (ขึ้นต้น otp_) กับ client_secret ทันที callback สองเส้น: /auth/sso-link/callback และ /auth/sso-otp/callbackbegin_step_up($username)# เก็บใน .env ของ backend — ห้ามใส่ใน JavaScript ฝั่งเบราว์เซอร์ SSO_API=https://sso.doae.go.th SSO_PORTAL=https://sso.doae.go.th SSO_CLIENT_ID=otp_ระบบคุณ SSO_CLIENT_SECRET=ได้รับครั้งเดียวตอนสมัคร APP_ORIGIN=https://your-app.doae.go.th
คนเข้าด้วยรหัสของแอปก่อน แล้วค่อยมา SSO ผลที่ได้คือ sso_user_id ไม่ใช่ Bearer
กล่อง = ขั้นทำงาน · ข้าวหลามตัด = จุดตัดสินใจ · ผลที่ได้คือ sso_user_id ไม่ใช่ Bearer
ผูกบัญชีแล้วหรือยัง?
1. คนใช้ / Browser → แอปของคุณ
กรอก user + รหัสของแอป
2. แอปของคุณ → SSO · backend
ขอเริ่มยืนยัน
POST /api/otp/step-up/start
3. SSO → แอปของคุณ · backend
ยังไม่ผูก
409 not_linked
4. แอปของคุณ → SSO · backend
เริ่มผูกบัญชี
POST /api/otp/link/start
5. แอปของคุณ → SSO
ส่งคนไปเข้า SSO
redirect otp_url
6. คนใช้ / Browser → SSO
เข้า SSO + รหัส 6 หลัก
7. SSO → แอปของคุณ
ส่ง code กลับแอป
/auth/sso-link/callback?code
8. แอปของคุณ → SSO · backend
แลก code
POST /api/otp/link/token
9. SSO → แอปของคุณ · backend
ผลลัพธ์
{ sso_user_id } + เก็บ mapping
10. แอปของคุณ → คนใช้ / Browser
เปิด session ของแอปเอง
ไม่มี Bearer
คนใช้ / Browser
แอปของคุณ
เว็บ + backend
SSO
sso.doae.go.th
1. คนใช้ / Browser → แอปของคุณ
กรอก user + รหัสของแอป
2. แอปของคุณ → SSO · backend
ขอเริ่มยืนยัน
POST /api/otp/step-up/start
3. SSO → แอปของคุณ · backend
ผูกแล้ว
200 otp_url
4. แอปของคุณ → SSO
ส่งคนไปยืนยัน
redirect otp_url
5. คนใช้ / Browser → SSO
รหัส 6 หลัก
6. SSO → แอปของคุณ
ส่ง code กลับแอป
/auth/sso-otp/callback?code
7. แอปของคุณ → SSO · backend
แลก code
POST /api/otp/step-up/token
8. SSO → แอปของคุณ · backend
ผลลัพธ์
{ sso_user_id }
9. แอปของคุณ → คนใช้ / Browser
เปิด session ของแอปเอง
ไม่มี Bearer
คนใช้ / Browser
แอปของคุณ
เว็บ + backend
SSO
sso.doae.go.th
รันอยู่ที่ step-up-php-test.doae.go.th
<?php
// ชุดเดียวกับที่รันบน https://step-up-php-test.doae.go.th
// หลังตรวจรหัสของระบบคุณผ่านแล้ว เรียก begin_step_up($username)
// เปิด 2 URL: GET /auth/sso-link/callback และ GET /auth/sso-otp/callback
function sso_post($path, $body) {
$api = rtrim(getenv('SSO_API') ?: 'https://sso.doae.go.th', '/');
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nAccept: application/json\r\n",
'content' => json_encode($body, JSON_UNESCAPED_SLASHES),
'timeout' => 15,
'ignore_errors' => true,
]]);
$raw = @file_get_contents($api . $path, false, $ctx);
$code = 0;
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
$code = (int) $m[1];
}
if ($raw === false) {
return [0, ['error' => 'http_error', 'message' => 'เรียก SSO ไม่ได้']];
}
$json = json_decode((string) $raw, true);
return [$code, is_array($json) ? $json : ['message' => (string) $raw]];
}
function sso_start($kind, $localUserId, $state) {
$origin = rtrim(getenv('APP_ORIGIN'), '/');
$path = $kind === 'link' ? '/auth/sso-link/callback' : '/auth/sso-otp/callback';
$endpoint = $kind === 'link' ? '/api/otp/link/start' : '/api/otp/step-up/start';
return sso_post($endpoint, [
'client_id' => getenv('SSO_CLIENT_ID'),
'client_secret' => getenv('SSO_CLIENT_SECRET'),
'local_user_id' => $localUserId,
'redirect_uri' => $origin . $path,
'state' => $state,
]);
}
function sso_exchange($kind, $code, $redirectUri) {
$endpoint = $kind === 'link' ? '/api/otp/link/token' : '/api/otp/step-up/token';
return sso_post($endpoint, [
'client_id' => getenv('SSO_CLIENT_ID'),
'client_secret' => getenv('SSO_CLIENT_SECRET'),
'code' => $code,
'redirect_uri' => $redirectUri,
]);
}
function begin_step_up($localUserId) {
$state = bin2hex(random_bytes(16));
$_SESSION['pending_state'] = $state;
$_SESSION['local_user_id'] = $localUserId;
[$code, $data] = sso_start('step_up', $localUserId, $state);
if ($code === 409 && ($data['error'] ?? '') === 'not_linked') {
$_SESSION['pending_kind'] = 'link';
[$code, $data] = sso_start('link', $localUserId, $state);
} else {
$_SESSION['pending_kind'] = 'step_up';
}
if ($code >= 200 && $code < 300 && !empty($data['otp_url'])) {
header('Location: ' . $data['otp_url'], true, 303);
exit;
}
$_SESSION['flash'] = $data['message'] ?? 'เริ่ม Step-up ไม่ได้';
header('Location: /login', true, 303);
exit;
}
function handle_callback($kind) {
$origin = rtrim(getenv('APP_ORIGIN'), '/');
$path = $kind === 'link' ? '/auth/sso-link/callback' : '/auth/sso-otp/callback';
if (!empty($_GET['error'])) {
$_SESSION['flash'] = 'ยกเลิกหรือไม่ผ่านการยืนยัน';
header('Location: /login');
exit;
}
$code = $_GET['code'] ?? '';
$state = $_GET['state'] ?? '';
$pending = $_SESSION['pending_state'] ?? '';
if ($code === '') {
$_SESSION['flash'] = 'ไม่มี code จาก SSO';
header('Location: /login');
exit;
}
if ($pending !== '' && $state !== $pending) {
$_SESSION['flash'] = 'state ไม่ตรง';
header('Location: /login');
exit;
}
[$status, $data] = sso_exchange($kind === 'link' ? 'link' : 'step_up', $code, $origin . $path);
if ($status < 200 || $status >= 300) {
$_SESSION['flash'] = $data['message'] ?? 'แลก code ไม่ได้';
header('Location: /login');
exit;
}
$_SESSION['verified'] = true;
$_SESSION['sso_user_id'] = $data['sso_user_id'] ?? '';
$_SESSION['local_user_id'] = $data['local_user_id'] ?? ($_SESSION['local_user_id'] ?? '');
unset($_SESSION['pending_state'], $_SESSION['pending_kind']);
header('Location: /');
exit;
}
// ใน router ของคุณ — หลังตรวจรหัสผ่านระบบนี้แล้ว
// begin_step_up($user);
// if ($path === '/auth/sso-link/callback') handle_callback('link');
// if ($path === '/auth/sso-otp/callback') handle_callback('otp');รันอยู่ที่ step-up-laravel-test.doae.go.th ตั้ง APP_URL ให้ตรง APP_ORIGIN
<?php
// ชุดเดียวกับที่รันบน https://step-up-laravel-test.doae.go.th
// วางที่ app/Services/StepUpClient.php
// ตั้ง APP_URL ใน .env ให้ตรง APP_ORIGIN เช่น https://ระบบคุณ.doae.go.th
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
class StepUpClient
{
public function start(string $kind, string $localUserId, string $state): array
{
$origin = rtrim((string) config('app.url'), '/');
$path = $kind === 'link' ? '/auth/sso-link/callback' : '/auth/sso-otp/callback';
$endpoint = $kind === 'link' ? '/api/otp/link/start' : '/api/otp/step-up/start';
return $this->post($endpoint, [
'client_id' => env('SSO_CLIENT_ID'),
'client_secret' => env('SSO_CLIENT_SECRET'),
'local_user_id' => $localUserId,
'redirect_uri' => $origin . $path,
'state' => $state,
]);
}
public function exchange(string $kind, string $code, string $redirectUri): array
{
$endpoint = $kind === 'link' ? '/api/otp/link/token' : '/api/otp/step-up/token';
return $this->post($endpoint, [
'client_id' => env('SSO_CLIENT_ID'),
'client_secret' => env('SSO_CLIENT_SECRET'),
'code' => $code,
'redirect_uri' => $redirectUri,
]);
}
private function post(string $endpoint, array $body): array
{
$url = rtrim((string) env('SSO_API', 'https://sso.doae.go.th'), '/') . $endpoint;
try {
$res = Http::timeout(8)->acceptJson()->post($url, $body);
} catch (ConnectionException $e) {
return [0, [
'error' => 'sso_unreachable',
'message' => 'SSO API ยังไม่ทำงานที่ ' . $url,
]];
}
return [$res->status(), $res->json() ?? ['message' => $res->body()]];
}
}<?php
// routes/web.php
Route::post('/login', [AuthController::class, 'login']);
Route::get('/auth/sso-link/callback', fn (Request $r, StepUpClient $sso) =>
app(AuthController::class)->callback($r, $sso, 'link'));
Route::get('/auth/sso-otp/callback', fn (Request $r, StepUpClient $sso) =>
app(AuthController::class)->callback($r, $sso, 'otp'));
// AuthController — หลัง Auth::attempt() หรือตรวจรหัสของระบบนี้ผ่าน แล้วเรียก login()
public function login(Request $request, StepUpClient $sso)
{
$user = $request->input('username'); // local_user_id ของระบบนี้
$state = bin2hex(random_bytes(16));
$request->session()->put('pending_state', $state);
$request->session()->put('local_user_id', $user);
[$status, $data] = $sso->start('step_up', $user, $state);
$kind = 'step_up';
if ($status === 409 && ($data['error'] ?? '') === 'not_linked') {
$kind = 'link';
[$status, $data] = $sso->start('link', $user, $state);
}
$request->session()->put('pending_kind', $kind);
if ($status >= 200 && $status < 300 && !empty($data['otp_url'])) {
return redirect()->away($data['otp_url']);
}
return back()->with('flash', $data['message'] ?? 'เริ่ม Step-up ไม่ได้');
}
public function callback(Request $request, StepUpClient $sso, string $kind)
{
if ($request->query('error')) {
return redirect('/login')->with('flash', 'ยกเลิกหรือไม่ผ่านการยืนยัน');
}
$code = (string) $request->query('code');
$state = (string) $request->query('state');
$pending = (string) $request->session()->get('pending_state', '');
if ($code === '') {
return redirect('/login')->with('flash', 'ไม่มี code จาก SSO');
}
if ($pending !== '' && $state !== $pending) {
return redirect('/login')->with('flash', 'state ไม่ตรง');
}
$origin = rtrim((string) config('app.url'), '/');
$path = $kind === 'link' ? '/auth/sso-link/callback' : '/auth/sso-otp/callback';
[$status, $data] = $sso->exchange($kind === 'link' ? 'link' : 'step_up', $code, $origin . $path);
if ($status < 200 || $status >= 300) {
return redirect('/login')->with('flash', $data['message'] ?? 'แลก code ไม่ได้');
}
$request->session()->put('verified', true);
$request->session()->put('sso_user_id', $data['sso_user_id'] ?? '');
$request->session()->put('sso_email', $data['email'] ?? '');
$request->session()->put('local_user_id', $data['local_user_id'] ?? $request->session()->get('local_user_id'));
$request->session()->forget(['pending_state', 'pending_kind']);
return redirect('/');
}รันอยู่ที่ step-up-express-test.doae.go.th
// ชุดเดียวกับที่รันบน https://step-up-express-test.doae.go.th
import express from 'express'
import session from 'express-session'
import { randomBytes } from 'node:crypto'
const app = express()
app.set('trust proxy', 1)
app.use(express.urlencoded({ extended: false }))
app.use(session({
secret: process.env.SSO_CLIENT_SECRET || 'dev',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: String(process.env.APP_ORIGIN || '').startsWith('https://'),
},
}))
async function postJson(url, body) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json().catch(() => ({ message: 'invalid json' }))
return [res.status, data]
} catch {
return [0, { error: 'sso_unreachable', message: 'SSO API ยังไม่ทำงานที่ ' + url }]
}
}
async function ssoStart(kind, localUserId, state) {
const api = (process.env.SSO_API || 'https://sso.doae.go.th').replace(/\/$/, '')
const origin = (process.env.APP_ORIGIN || '').replace(/\/$/, '')
const path = kind === 'link' ? '/auth/sso-link/callback' : '/auth/sso-otp/callback'
const endpoint = kind === 'link' ? '/api/otp/link/start' : '/api/otp/step-up/start'
return postJson(api + endpoint, {
client_id: process.env.SSO_CLIENT_ID,
client_secret: process.env.SSO_CLIENT_SECRET,
local_user_id: localUserId,
redirect_uri: origin + path,
state,
})
}
async function ssoExchange(kind, code, redirectUri) {
const api = (process.env.SSO_API || 'https://sso.doae.go.th').replace(/\/$/, '')
const endpoint = kind === 'link' ? '/api/otp/link/token' : '/api/otp/step-up/token'
return postJson(api + endpoint, {
client_id: process.env.SSO_CLIENT_ID,
client_secret: process.env.SSO_CLIENT_SECRET,
code,
redirect_uri: redirectUri,
})
}
async function beginStepUp(req, res, localUserId) {
const state = randomBytes(16).toString('hex')
req.session.pending_state = state
req.session.local_user_id = localUserId
let [status, data] = await ssoStart('step_up', localUserId, state)
let kind = 'step_up'
if (status === 409 && data.error === 'not_linked') {
kind = 'link'
;[status, data] = await ssoStart('link', localUserId, state)
}
req.session.pending_kind = kind
if (status >= 200 && status < 300 && data.otp_url) {
return req.session.save(() => res.redirect(data.otp_url))
}
req.session.flash = data.message || 'เริ่ม Step-up ไม่ได้'
return req.session.save(() => res.redirect('/login'))
}
async function handleCallback(req, res, kind) {
if (req.query.error) {
req.session.flash = 'ยกเลิกหรือไม่ผ่านการยืนยัน'
return res.redirect('/login')
}
const code = String(req.query.code || '')
const state = String(req.query.state || '')
const pending = req.session.pending_state || ''
if (!code) {
req.session.flash = 'ไม่มี code จาก SSO'
return res.redirect('/login')
}
if (pending && state !== pending) {
req.session.flash = 'state ไม่ตรง'
return res.redirect('/login')
}
const origin = (process.env.APP_ORIGIN || '').replace(/\/$/, '')
const path = kind === 'link' ? '/auth/sso-link/callback' : '/auth/sso-otp/callback'
const [status, data] = await ssoExchange(kind === 'link' ? 'link' : 'step_up', code, origin + path)
if (status < 200 || status >= 300) {
req.session.flash = data.message || 'แลก code ไม่ได้'
return res.redirect('/login')
}
req.session.verified = true
req.session.sso_user_id = data.sso_user_id || ''
req.session.sso_email = data.email || ''
req.session.local_user_id = data.local_user_id || req.session.local_user_id
delete req.session.pending_state
delete req.session.pending_kind
req.session.save(() => res.redirect('/'))
}
app.post('/login', async (req, res) => {
// ตรวจรหัสของระบบนี้ก่อน แล้วค่อย
await beginStepUp(req, res, String(req.body.username || '').trim())
})
app.get('/auth/sso-link/callback', (req, res) => handleCallback(req, res, 'link'))
app.get('/auth/sso-otp/callback', (req, res) => handleCallback(req, res, 'otp'))รันอยู่ที่ step-up-nextjs-test.doae.go.th เรียกจาก Route Handler เท่านั้น ตั้ง APP_ORIGIN ห้ามใช้ req.url
// lib/sso.js — ชุดเดียวกับ https://step-up-nextjs-test.doae.go.th
// ต้องตั้ง APP_ORIGIN=https://ระบบคุณ.doae.go.th
// ห้ามใช้ new URL(..., req.url) จะเด้งไป :3000 หลัง Traefik
export function appOrigin(req) {
const fromEnv = String(process.env.APP_ORIGIN || '').replace(/\/$/, '')
if (fromEnv) return fromEnv
if (req) {
const host = req.headers.get('x-forwarded-host') || req.headers.get('host') || ''
const internal = !host || host.includes('0.0.0.0') || /^localhost(:\d+)?$/i.test(host)
if (!internal) {
const proto = req.headers.get('x-forwarded-proto') || 'https'
return proto + '://' + host
}
}
return 'http://127.0.0.1:4104'
}
export function appUrl(path, req) {
return new URL(path, appOrigin(req) + '/')
}
export async function postJson(url, body) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json().catch(() => ({ message: 'invalid json' }))
return [res.status, data]
} catch {
return [0, { error: 'sso_unreachable', message: 'SSO API ยังไม่ทำงานที่ ' + url }]
}
}
export async function ssoStart(kind, localUserId, state) {
const api = (process.env.SSO_API || 'https://sso.doae.go.th').replace(/\/$/, '')
const origin = appOrigin().replace(/\/$/, '')
const path = kind === 'link' ? '/auth/sso-link/callback' : '/auth/sso-otp/callback'
const endpoint = kind === 'link' ? '/api/otp/link/start' : '/api/otp/step-up/start'
return postJson(api + endpoint, {
client_id: process.env.SSO_CLIENT_ID,
client_secret: process.env.SSO_CLIENT_SECRET,
local_user_id: localUserId,
redirect_uri: origin + path,
state,
})
}
export async function ssoExchange(kind, code, redirectUri) {
const api = (process.env.SSO_API || 'https://sso.doae.go.th').replace(/\/$/, '')
const endpoint = kind === 'link' ? '/api/otp/link/token' : '/api/otp/step-up/token'
return postJson(api + endpoint, {
client_id: process.env.SSO_CLIENT_ID,
client_secret: process.env.SSO_CLIENT_SECRET,
code,
redirect_uri: redirectUri,
})
}// app/api/login/route.js — รันบนเซิร์ฟเวอร์เท่านั้น
import { randomBytes } from 'node:crypto'
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { appUrl, ssoStart } from '../../../lib/sso'
export async function POST(req) {
const form = await req.formData()
const user = String(form.get('username') || '').trim()
const loginUrl = appUrl('/login', req)
// ตรวจรหัสของระบบนี้ก่อน แล้วค่อยเรียก SSO
const state = randomBytes(16).toString('hex')
cookies().set('pending_state', state, { httpOnly: true, sameSite: 'lax', path: '/' })
cookies().set('local_user_id', user, { httpOnly: true, sameSite: 'lax', path: '/' })
let [status, data] = await ssoStart('step_up', user, state)
let kind = 'step_up'
if (status === 409 && data.error === 'not_linked') {
kind = 'link'
;[status, data] = await ssoStart('link', user, state)
}
cookies().set('pending_kind', kind, { httpOnly: true, sameSite: 'lax', path: '/' })
if (status >= 200 && status < 300 && data.otp_url) {
return NextResponse.redirect(data.otp_url, 303)
}
loginUrl.searchParams.set('error', data.message || 'เริ่ม Step-up ไม่ได้')
return NextResponse.redirect(loginUrl, 303)
}// lib/callback.js
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { appUrl, ssoExchange } from './sso'
export async function finishCallback(req, kind) {
const url = new URL(req.url)
const login = appUrl('/login', req)
if (url.searchParams.get('error')) {
login.searchParams.set('error', 'ยกเลิกหรือไม่ผ่านการยืนยัน')
return NextResponse.redirect(login, 303)
}
const code = url.searchParams.get('code') || ''
const state = url.searchParams.get('state') || ''
const pending = cookies().get('pending_state')?.value || ''
if (!code) {
login.searchParams.set('error', 'ไม่มี code จาก SSO')
return NextResponse.redirect(login, 303)
}
if (pending && state !== pending) {
login.searchParams.set('error', 'state ไม่ตรง')
return NextResponse.redirect(login, 303)
}
const origin = appUrl('/', req).origin.replace(/\/$/, '')
const path = kind === 'link' ? '/auth/sso-link/callback' : '/auth/sso-otp/callback'
const [status, data] = await ssoExchange(kind === 'link' ? 'link' : 'step_up', code, origin + path)
if (status < 200 || status >= 300) {
login.searchParams.set('error', data.message || 'แลก code ไม่ได้')
return NextResponse.redirect(login, 303)
}
// ตั้ง session ของแอปเอง จาก data.sso_user_id
cookies().set('sso_user_id', data.sso_user_id || '', { httpOnly: true, sameSite: 'lax', path: '/' })
cookies().set('local_user_id', data.local_user_id || cookies().get('local_user_id')?.value || '', {
httpOnly: true, sameSite: 'lax', path: '/',
})
cookies().set('verified', '1', { httpOnly: true, sameSite: 'lax', path: '/' })
return NextResponse.redirect(appUrl('/', req), 303)
}
// app/auth/sso-link/callback/route.js
// import { finishCallback } from '../../../../lib/callback'
// export async function GET(req) { return finishCallback(req, 'link') }
// app/auth/sso-otp/callback/route.js
// import { finishCallback } from '../../../../lib/callback'
// export async function GET(req) { return finishCallback(req, 'otp') }import os, secrets, requests
from flask import Flask, request, session, redirect
app = Flask(__name__)
app.secret_key = os.environ["SSO_CLIENT_SECRET"]
SSO = os.environ["SSO_API"].rstrip("/")
ORIGIN = os.environ["APP_ORIGIN"].rstrip("/")
def sso_post(path, body):
r = requests.post(SSO + path, json=body, timeout=8)
return r.status_code, r.json() if r.content else {}
def begin_step_up(local_user_id):
state = secrets.token_hex(16)
session["pending_state"] = state
session["local_user_id"] = local_user_id
base = {
"client_id": os.environ["SSO_CLIENT_ID"],
"client_secret": os.environ["SSO_CLIENT_SECRET"],
"local_user_id": local_user_id,
"state": state,
}
status, data = sso_post("/api/otp/step-up/start", {
**base, "redirect_uri": ORIGIN + "/auth/sso-otp/callback",
})
kind = "step_up"
if status == 409 and data.get("error") == "not_linked":
kind = "link"
status, data = sso_post("/api/otp/link/start", {
**base, "redirect_uri": ORIGIN + "/auth/sso-link/callback",
})
session["pending_kind"] = kind
if 200 <= status < 300 and data.get("otp_url"):
return redirect(data["otp_url"])
return redirect("/login")
@app.post("/login")
def login():
# ตรวจรหัสของระบบนี้ก่อน
return begin_step_up(request.form["username"])
@app.get("/auth/sso-otp/callback")
@app.get("/auth/sso-link/callback")
def callback():
if request.args.get("error"):
return redirect("/login")
code, state = request.args.get("code", ""), request.args.get("state", "")
if not code or state != session.get("pending_state"):
return redirect("/login")
kind = "link" if request.path.endswith("sso-link/callback") else "step_up"
path = "/auth/sso-link/callback" if kind == "link" else "/auth/sso-otp/callback"
endpoint = "/api/otp/link/token" if kind == "link" else "/api/otp/step-up/token"
status, data = sso_post(endpoint, {
"client_id": os.environ["SSO_CLIENT_ID"],
"client_secret": os.environ["SSO_CLIENT_SECRET"],
"code": code,
"redirect_uri": ORIGIN + path,
})
if not (200 <= status < 300):
return redirect("/login")
session["verified"] = True
session["sso_user_id"] = data.get("sso_user_id")
session["local_user_id"] = data.get("local_user_id") or session.get("local_user_id")
return redirect("/")package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"os"
)
func ssoPost(path string, body any) (int, map[string]any) {
raw, _ := json.Marshal(body)
resp, err := http.Post(os.Getenv("SSO_API")+path, "application/json", bytes.NewReader(raw))
if err != nil {
return 0, map[string]any{"message": err.Error()}
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
func beginStepUp(w http.ResponseWriter, r *http.Request, localUserID string, save func(state, kind string)) {
buf := make([]byte, 16)
_, _ = rand.Read(buf)
state := hex.EncodeToString(buf)
origin := os.Getenv("APP_ORIGIN")
base := map[string]string{
"client_id": os.Getenv("SSO_CLIENT_ID"),
"client_secret": os.Getenv("SSO_CLIENT_SECRET"),
"local_user_id": localUserID,
"state": state,
}
start := func(kind, redirect string) (int, map[string]any) {
body := map[string]string{}
for k, v := range base {
body[k] = v
}
body["redirect_uri"] = origin + redirect
path := "/api/otp/step-up/start"
if kind == "link" {
path = "/api/otp/link/start"
}
return ssoPost(path, body)
}
status, data := start("step_up", "/auth/sso-otp/callback")
kind := "step_up"
if status == 409 {
if err, _ := data["error"].(string); err == "not_linked" {
kind = "link"
status, data = start("link", "/auth/sso-link/callback")
}
}
save(state, kind)
if status >= 200 && status < 300 {
if url, _ := data["otp_url"].(string); url != "" {
http.Redirect(w, r, url, http.StatusSeeOther)
return
}
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}// ASP.NET Core — เรียกจาก controller หลังตรวจรหัสของระบบนี้ผ่าน
using System.Net.Http.Json;
public class StepUpClient
{
private readonly HttpClient _http;
private readonly IConfiguration _cfg;
public StepUpClient(HttpClient http, IConfiguration cfg) { _http = http; _cfg = cfg; }
public async Task<(int Status, JsonElement Body)> Start(string kind, string localUserId, string state)
{
var origin = _cfg["APP_ORIGIN"]!.TrimEnd('/');
var path = kind == "link" ? "/auth/sso-link/callback" : "/auth/sso-otp/callback";
var endpoint = kind == "link" ? "/api/otp/link/start" : "/api/otp/step-up/start";
return await Post(endpoint, new {
client_id = _cfg["SSO_CLIENT_ID"],
client_secret = _cfg["SSO_CLIENT_SECRET"],
local_user_id = localUserId,
redirect_uri = origin + path,
state
});
}
public async Task<(int Status, JsonElement Body)> Exchange(string kind, string code, string redirectUri)
{
var endpoint = kind == "link" ? "/api/otp/link/token" : "/api/otp/step-up/token";
return await Post(endpoint, new {
client_id = _cfg["SSO_CLIENT_ID"],
client_secret = _cfg["SSO_CLIENT_SECRET"],
code,
redirect_uri = redirectUri
});
}
private async Task<(int, JsonElement)> Post(string endpoint, object body)
{
var res = await _http.PostAsJsonAsync(_cfg["SSO_API"]!.TrimEnd('/') + endpoint, body);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
return ((int)res.StatusCode, json);
}
}
// ใน Login POST
var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
HttpContext.Session.SetString("pending_state", state);
var (status, data) = await _sso.Start("step_up", username, state);
var kind = "step_up";
if (status == 409 && data.GetProperty("error").GetString() == "not_linked")
{
kind = "link";
(status, data) = await _sso.Start("link", username, state);
}
if (status is >= 200 and < 300 && data.TryGetProperty("otp_url", out var url))
return Redirect(url.GetString()!);// Java 11+ / Spring — RestClient หรือ HttpClient
HttpClient http = HttpClient.newHttpClient();
record StartBody(String client_id, String client_secret, String local_user_id,
String redirect_uri, String state) {}
String post(String path, Object body) throws Exception {
String json = new ObjectMapper().writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(System.getenv("SSO_API") + path))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
return res.statusCode() + "\n" + res.body();
}
String state = HexFormat.of().formatHex(new SecureRandom().generateSeed(16));
String origin = System.getenv("APP_ORIGIN");
StartBody stepUp = new StartBody(
System.getenv("SSO_CLIENT_ID"),
System.getenv("SSO_CLIENT_SECRET"),
localUserId,
origin + "/auth/sso-otp/callback",
state
);
// POST /api/otp/step-up/start
// ถ้า 409 error=not_linked → POST /api/otp/link/start โดย redirect_uri เป็น /auth/sso-link/callback
// response.otp_url → response.sendRedirect(otp_url)
// callback: POST /api/otp/step-up/token หรือ /api/otp/link/token
// body: client_id, client_secret, code, redirect_uri ชุดเดียวกับตอน startCodeIgniter 3 ต้อง map URL ที่มีขีดใน routes.php ให้ตรงที่ลงทะเบียน
<?php
// application/config/routes.php — URL ต้องมีขีด ตรงกับที่ลงทะเบียนที่ SSO
$route['auth/sso-link/callback'] = 'auth/callback_link';
$route['auth/sso-otp/callback'] = 'auth/callback_otp';
// application/controllers/Auth.php — CodeIgniter 3
class Auth extends CI_Controller
{
public function login()
{
$user = $this->input->post('username');
// ตรวจรหัสของระบบนี้ก่อน แล้ว
$state = bin2hex(random_bytes(16));
$this->session->set_userdata('pending_state', $state);
$this->session->set_userdata('local_user_id', $user);
$origin = rtrim(getenv('APP_ORIGIN'), '/');
$payload = [
'client_id' => getenv('SSO_CLIENT_ID'),
'client_secret' => getenv('SSO_CLIENT_SECRET'),
'local_user_id' => $user,
'redirect_uri' => $origin . '/auth/sso-otp/callback',
'state' => $state,
];
$res = $this->sso_post('/api/otp/step-up/start', $payload);
if ($res['status'] == 409 && ($res['json']['error'] ?? '') === 'not_linked') {
$payload['redirect_uri'] = $origin . '/auth/sso-link/callback';
$res = $this->sso_post('/api/otp/link/start', $payload);
}
if (!empty($res['json']['otp_url'])) {
redirect($res['json']['otp_url'], 'location', 303);
}
$this->session->set_flashdata('error', $res['json']['message'] ?? 'เริ่ม Step-up ไม่ได้');
redirect('login');
}
public function callback_link() { $this->handle_callback('link'); }
public function callback_otp() { $this->handle_callback('otp'); }
private function handle_callback($kind)
{
if ($this->input->get('error')) {
$this->session->set_flashdata('error', 'ยกเลิกหรือไม่ผ่านการยืนยัน');
redirect('login');
}
$code = (string) $this->input->get('code');
$state = (string) $this->input->get('state');
$pending = (string) $this->session->userdata('pending_state');
if ($code === '' || ($pending !== '' && $state !== $pending)) {
$this->session->set_flashdata('error', 'state ไม่ตรง');
redirect('login');
}
$origin = rtrim(getenv('APP_ORIGIN'), '/');
$path = $kind === 'link' ? '/auth/sso-link/callback' : '/auth/sso-otp/callback';
$endpoint = $kind === 'link' ? '/api/otp/link/token' : '/api/otp/step-up/token';
$res = $this->sso_post($endpoint, [
'client_id' => getenv('SSO_CLIENT_ID'),
'client_secret' => getenv('SSO_CLIENT_SECRET'),
'code' => $code,
'redirect_uri' => $origin . $path,
]);
if ($res['status'] < 200 || $res['status'] >= 300) {
$this->session->set_flashdata('error', $res['json']['message'] ?? 'แลก code ไม่ได้');
redirect('login');
}
$this->session->set_userdata('verified', true);
$this->session->set_userdata('sso_user_id', $res['json']['sso_user_id'] ?? '');
$this->session->unset_userdata('pending_state');
redirect('/');
}
private function sso_post($path, $body)
{
$ch = curl_init(rtrim(getenv('SSO_API') ?: 'https://sso.doae.go.th', '/') . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'],
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'json' => json_decode($raw, true) ?: []];
}
}เข้า SSO ของกรมก่อน แล้วกรอกที่ แบบฟอร์มสมัคร ได้ credentials ทันที ไม่ต้องรอแอดมิน Callback ต้องเป็น https บน *.doae.go.th
| ค่า | ความหมาย |
|---|---|
link_callback_uri | ครั้งแรก ผูกบัญชี — https://ระบบคุณ.doae.go.th/auth/sso-link/callback |
otp_callback_uri | ครั้งถัดไป ยืนยันเพิ่มเติม — https://ระบบคุณ.doae.go.th/auth/sso-otp/callback |
สมัครด้วย API ก็ได้ ถ้าแอปเรียกเอง
POST https://sso.doae.go.th/api/integrations/apply
Authorization: Bearer {access_token ของบัญชี SSO}
Content-Type: application/json
{
"system_name": "ระบบพัสดุ",
"description": "เบิกจ่ายพัสดุหน่วยงาน",
"link_callback_uri": "https://your-app.doae.go.th/auth/sso-link/callback",
"otp_callback_uri": "https://your-app.doae.go.th/auth/sso-otp/callback",
"otp_methods": ["email", "doae_id"]
}
# ชื่อ เมล หน่วยงาน โทรศัพท์ดึงจากบัญชีที่ล็อกอิน ไม่รับจาก body{
"success": true,
"application_id": "uuid",
"status": "approved",
"client_id": "otp_...",
"client_secret": "แสดงครั้งเดียว",
"status_token": "เก็บไว้สอบสถานะ",
"status_url": "/api/integrations/applications/{id}"
}| error | แอปควรทำ |
|---|---|
not_linked | เรียก /api/otp/link/start ด้วย local_user_id เดิม |
invalid_client | ตรวจ client_id / secret |
invalid_redirect_uri | URI ต้องตรงกับที่ลงทะเบียนทุกตัวอักษร รวม https |
invalid_code | code ใช้แล้วหรือหมดอายุ เริ่ม start ใหม่ |
conflict | local_user_id นี้ถูกผูกกับบัญชี SSO อื่นแล้ว |
client_secret อยู่แค่ backend แลก code ที่เซิร์ฟเวอร์ ตรวจ stateผลลัพธ์ไม่ใช่ Bearer — เก็บ sso_user_id แล้วเปิด session ของแอปเอง
คนกดข้ามระบบจากหน้าแอปของ SSO ได้ แอปคุณไม่ต้องวาดปุ่ม ปลายทางแลก code ที่ /api/otp/step-up/token เหมือนครั้งถัดไป
เปิดในเบราว์เซอร์ (คนกดจากหน้าแอปของ SSO ก็ได้) GET https://sso.doae.go.th/otp/bridge ?client_id=otp_ระบบปลายทาง &redirect_uri=https://app-b.doae.go.th/auth/sso-otp/callback &state=สุ่ม แอปปลายทางแลก code ที่ backend เหมือน step-up/token
JSON ของ API ถ้าอยากดูเอง
POST https://sso.doae.go.th/api/otp/step-up/start
Content-Type: application/json
{
"client_id": "otp_ระบบคุณ",
"client_secret": "...",
"local_user_id": "somchai",
"redirect_uri": "https://your-app.doae.go.th/auth/sso-otp/callback",
"state": "สุ่มจาก backend เก็บใน session"
}{
"otp_url": "https://sso.doae.go.th/otp/step-up?sid=...",
"expires_in": 300
}{
"error": "not_linked",
"message": "ยังไม่ผูกบัญชี — ไปเส้น /api/otp/link/start"
}POST https://sso.doae.go.th/api/otp/link/start
Content-Type: application/json
{
"client_id": "otp_ระบบคุณ",
"client_secret": "...",
"local_user_id": "somchai",
"redirect_uri": "https://your-app.doae.go.th/auth/sso-link/callback",
"state": "ค่าเดียวกับที่เก็บใน session"
}
# 200
{ "otp_url": "https://sso.doae.go.th/otp/link?sid=...", "expires_in": 300 }https://sso.doae.go.th redirect คนกลับมาที่ redirect_uri ที่ส่งตอน start
สำเร็จ: GET {redirect_uri}?code=...&state=...
ยกเลิก: GET {redirect_uri}?error=access_denied&state=...POST https://sso.doae.go.th/api/otp/link/token
Content-Type: application/json
{
"client_id": "otp_ระบบคุณ",
"client_secret": "...",
"code": "จาก query",
"redirect_uri": "https://your-app.doae.go.th/auth/sso-link/callback"
}
# 200 — ไม่มี access_token
{
"sso_user_id": "uuid ของบัญชี SSO",
"email": "name@doae.go.th",
"full_name": "สมชาย จันทร์",
"already_linked": false
}POST https://sso.doae.go.th/api/otp/step-up/token
Content-Type: application/json
{
"client_id": "otp_ระบบคุณ",
"client_secret": "...",
"code": "จาก query",
"redirect_uri": "https://your-app.doae.go.th/auth/sso-otp/callback"
}
# 200 — ไม่มี access_token
{
"ok": true,
"sso_user_id": "uuid",
"local_user_id": "somchai",
"amr": ["pwd", "totp"]
}กลุ่มระบบเครือข่ายคอมพิวเตอร์และความมั่นคงปลอดภัยทางไซเบอร์ ศสท. กรมส่งเสริมการเกษตร