คู่มือแอปที่มีบัญชีผู้ใช้ (Step-up)

โค้ดด้านล่างคือชุดเดียวกับที่รันบนโดเมนทดสอบ — คัดลอก ใส่ .env แล้วเรียกหลังตรวจรหัสแอป

SSO Horizon · https://sso.doae.go.th

เลือกเส้นทาง

ทำ 3 อย่าง

  1. ลองของจริงก่อน — ล็อกอินในแอปด้วย somchai หรือ wanee รหัส password123แล้วเข้าบัญชี SSO ของกรมตัวเอง
  2. สมัครที่ แบบฟอร์มสมัคร ได้ client_id (ขึ้นต้น otp_) กับ client_secret ทันที callback สองเส้น: /auth/sso-link/callback และ /auth/sso-otp/callback
  3. วาง .env แล้วคัดลอกโค้ดภาษาคุณ หลังตรวจรหัสของระบบคุณผ่าน เรียก begin_step_up($username)
.env ของ backend — ห้ามใส่ในเบราว์เซอร์
# เก็บใน .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

System Flow

คนเข้าด้วยรหัสของแอปก่อน แล้วค่อยมา SSO ผลที่ได้คือ sso_user_id ไม่ใช่ Bearer

Flowchart — แอปที่มีบัญชีผู้ใช้เอง

กล่อง = ขั้นทำงาน · ข้าวหลามตัด = จุดตัดสินใจ · ผลที่ได้คือ sso_user_id ไม่ใช่ Bearer

ตรวจรหัสของแอปผ่านแล้ว
ขอเริ่มยืนยันPOST /api/otp/step-up/start

ผูกบัญชีแล้วหรือยัง?

ใช่
ครั้งถัดไป · 200redirect otp_url
รหัส 6 หลัก
แลก codePOST /api/otp/step-up/token
ไม่
ครั้งแรก · 409 not_linkedPOST /api/otp/link/start
เข้า SSO + รหัส 6 หลัก
แลก code + เก็บ mappingPOST /api/otp/link/token
เปิด session ของแอปเองไม่มี Bearer จาก SSO
ครั้งแรก — ผูกบัญชี
  1. 1. คนใช้ / Browserแอปของคุณ

    กรอก user + รหัสของแอป

  2. 2. แอปของคุณSSO · backend

    ขอเริ่มยืนยัน

    POST /api/otp/step-up/start

  3. 3. SSOแอปของคุณ · backend

    ยังไม่ผูก

    409 not_linked

  4. 4. แอปของคุณSSO · backend

    เริ่มผูกบัญชี

    POST /api/otp/link/start

  5. 5. แอปของคุณSSO

    ส่งคนไปเข้า SSO

    redirect otp_url

  6. 6. คนใช้ / BrowserSSO

    เข้า SSO + รหัส 6 หลัก

  7. 7. SSOแอปของคุณ

    ส่ง code กลับแอป

    /auth/sso-link/callback?code

  8. 8. แอปของคุณSSO · backend

    แลก code

    POST /api/otp/link/token

  9. 9. SSOแอปของคุณ · backend

    ผลลัพธ์

    { sso_user_id } + เก็บ mapping

  10. 10. แอปของคุณคนใช้ / Browser

    เปิด session ของแอปเอง

    ไม่มี Bearer

ครั้งถัดไป — ยืนยันรหัส 6 หลัก
  1. 1. คนใช้ / Browserแอปของคุณ

    กรอก user + รหัสของแอป

  2. 2. แอปของคุณSSO · backend

    ขอเริ่มยืนยัน

    POST /api/otp/step-up/start

  3. 3. SSOแอปของคุณ · backend

    ผูกแล้ว

    200 otp_url

  4. 4. แอปของคุณSSO

    ส่งคนไปยืนยัน

    redirect otp_url

  5. 5. คนใช้ / BrowserSSO

    รหัส 6 หลัก

  6. 6. SSOแอปของคุณ

    ส่ง code กลับแอป

    /auth/sso-otp/callback?code

  7. 7. แอปของคุณSSO · backend

    แลก code

    POST /api/otp/step-up/token

  8. 8. SSOแอปของคุณ · backend

    ผลลัพธ์

    { sso_user_id }

  9. 9. แอปของคุณคนใช้ / Browser

    เปิด session ของแอปเอง

    ไม่มี Bearer

PHP

รันอยู่ที่ step-up-php-test.doae.go.th

วางไฟล์นี้ แล้วเรียก begin_step_up หลังตรวจรหัส
<?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');

Laravel

รันอยู่ที่ step-up-laravel-test.doae.go.th ตั้ง APP_URL ให้ตรง APP_ORIGIN

app/Services/StepUpClient.php
<?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()]];
    }
}
routes/web.php + AuthController
<?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('/');
}

Express / Node.js

รันอยู่ที่ step-up-express-test.doae.go.th

server.js
// ชุดเดียวกับที่รันบน 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'))

Next.js

รันอยู่ที่ step-up-nextjs-test.doae.go.th เรียกจาก Route Handler เท่านั้น ตั้ง APP_ORIGIN ห้ามใช้ req.url

lib/sso.js
// 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
// 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 + 2 route
// 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') }

ภาษาอื่น — โปรโตคอลเดียวกัน

Python (Flask)
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("/")
Go
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)
}
C# / ASP.NET
// 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
// 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 ชุดเดียวกับตอน start

CodeIgniter 3 ต้อง map URL ที่มีขีดใน routes.php ให้ตรงที่ลงทะเบียน

CodeIgniter 3
<?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 ก็ได้ ถ้าแอปเรียกเอง

สมัครด้วย 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_uriURI ต้องตรงกับที่ลงทะเบียนทุกตัวอักษร รวม https
invalid_codecode ใช้แล้วหรือหมดอายุ เริ่ม start ใหม่
conflictlocal_user_id นี้ถูกผูกกับบัญชี SSO อื่นแล้ว

client_secret อยู่แค่ backend แลก code ที่เซิร์ฟเวอร์ ตรวจ stateผลลัพธ์ไม่ใช่ Bearer — เก็บ sso_user_id แล้วเปิด session ของแอปเอง

คนกดข้ามระบบจากหน้าแอปของ SSO ได้ แอปคุณไม่ต้องวาดปุ่ม ปลายทางแลก code ที่ /api/otp/step-up/token เหมือนครั้งถัดไป

URL สะพานเชื่อม
เปิดในเบราว์เซอร์ (คนกดจากหน้าแอปของ 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 /api/otp/step-up/start
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"
}
200
{
  "otp_url": "https://sso.doae.go.th/otp/step-up?sid=...",
  "expires_in": 300
}
409 ยังไม่ผูก
{
  "error": "not_linked",
  "message": "ยังไม่ผูกบัญชี — ไปเส้น /api/otp/link/start"
}
POST /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 }
SSO ส่งคนกลับแอป
https://sso.doae.go.th redirect คนกลับมาที่ redirect_uri ที่ส่งตอน start

สำเร็จ:  GET {redirect_uri}?code=...&state=...
ยกเลิก:  GET {redirect_uri}?error=access_denied&state=...
แลก code ครั้งแรก
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
}
แลก code ครั้งถัดไป
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"]
}

ติดต่อ

กลุ่มระบบเครือข่ายคอมพิวเตอร์และความมั่นคงปลอดภัยทางไซเบอร์ ศสท. กรมส่งเสริมการเกษตร

helpdesk@doae.go.th · สมัครใช้บริการ