Giới thiệu về iKame SSO

iKame SSO là một dịch vụ Single Sign-On tập trung, cho phép người dùng đăng nhập một lần và truy cập nhiều service khác nhau trong hệ sinh thái iKame mà không cần đăng nhập lại.

SSO Service là gì?

Single Sign-On (SSO) là một phương thức xác thực cho phép người dùng đăng nhập một lần với một bộ credentials và được tự động đăng nhập vào nhiều ứng dụng hoặc website khác nhau. iKame SSO sử dụng Keycloak và Google OAuth để cung cấp giải pháp SSO an toàn và dễ sử dụng.

Lợi ích của SSO

  • Single Sign-On: Đăng nhập một lần, sử dụng nhiều service - Người dùng chỉ cần đăng nhập một lần với Google và có thể truy cập tất cả các service trong hệ sinh thái iKame.
  • Bảo mật tập trung: Tất cả việc xác thực được quản lý tập trung qua Keycloak, giảm thiểu rủi ro bảo mật và dễ dàng cập nhật chính sách bảo mật.
  • Giảm thiểu quản lý credentials: Không cần lưu trữ và quản lý mật khẩu riêng cho từng service, giảm gánh nặng cho cả người dùng và developers.
  • Trải nghiệm người dùng tốt hơn: Người dùng không cần nhớ nhiều tài khoản và mật khẩu, quá trình đăng nhập nhanh chóng và thuận tiện.
  • Dễ dàng tích hợp: Chỉ cần redirect user đến SSO endpoint và nhận token từ callback, không cần cấu hình phức tạp.
  • Giảm chi phí bảo trì: Quản lý tập trung giúp giảm chi phí bảo trì và cập nhật hệ thống xác thực.

Flow tổng quan

User → Service → SSO Login → Google OAuth → Keycloak → SSO Callback → Service với Token

Khi người dùng cần đăng nhập, service sẽ redirect họ đến SSO endpoint. Sau khi đăng nhập thành công với Google qua Keycloak, SSO sẽ redirect về service kèm theo access token để service có thể xác thực người dùng.

Quick Start

Đây là cách nhanh nhất để tích hợp SSO vào service của bạn:

Bước 1: Redirect user đến SSO

Khi user cần đăng nhập, redirect họ đến SSO endpoint với tham số urlDirect:

JavaScript
// Redirect user to SSO login
const serviceUrl = 'https://your-service.com/dashboard';
const ssoUrl = `https://sso.ikameglobal.com/?urlDirect=${encodeURIComponent(serviceUrl)}`;
window.location.href = ssoUrl;

Bước 2: Nhận token từ callback

Sau khi user đăng nhập thành công, SSO sẽ redirect về service của bạn với token trong hash fragment:

JavaScript
// Get ssoToken from URL hash fragment
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const ssoToken = hashParams.get('ssoToken');

if (ssoToken) {
    // Store ssoToken and use it for authentication
    localStorage.setItem('authToken', ssoToken);
    // Remove hash from URL
    window.history.replaceState({}, '', window.location.pathname + window.location.search);
    // Redirect to your app or make authenticated requests
}
Lưu ý: Token được truyền qua hash fragment (không gửi lên server). Bạn cần đọc token bằng JavaScript và validate token trên server-side để đảm bảo bảo mật.

Hướng dẫn tích hợp chi tiết

Hướng dẫn từng bước để tích hợp SSO vào service của bạn:

Bước 1: Redirect user đến SSO endpoint

Khi user cần đăng nhập hoặc chưa được xác thực, redirect họ đến:

URL Format
https://sso.ikameglobal.com/?urlDirect={your_service_url}

urlDirect là URL mà SSO sẽ redirect về sau khi login thành công. URL này phải được encode đúng cách.

Bước 2: User login với Google

User sẽ được redirect đến trang login SSO, sau đó chọn "Sign in with Google". Quá trình xác thực được xử lý bởi Keycloak và Google OAuth.

Bước 3: Nhận token từ callback URL

Sau khi login thành công, SSO sẽ redirect về urlDirect với token trong hash fragment:

Callback URL Format
{your_service_url}#ssoToken={access_token}

Bước 4: Validate và sử dụng token

Service của bạn cần validate token (tốt nhất là trên server-side) và sử dụng token để xác thực các request tiếp theo.

Flow diagram chi tiết

1. User truy cập service 2. Service kiểm tra authentication 3. Nếu chưa authenticated → Redirect đến: https://sso.ikameglobal.com/?urlDirect={service_url} 4. User click "Sign in with Google" 5. Redirect đến Google OAuth 6. User đăng nhập với Google 7. Google redirect về Keycloak 8. Keycloak xử lý và redirect về SSO 9. SSO redirect về service với ssoToken trong hash fragment: {service_url}#ssoToken={access_token} 10. Service đọc ssoToken từ hash fragment bằng JavaScript, validate và authenticate user

Ví dụ code

Dưới đây là các ví dụ code cho các ngôn ngữ và framework phổ biến:

JavaScript / HTML (Client-side)

JavaScript
// Check if user is authenticated
function checkAuth() {
    const ssoToken = localStorage.getItem('authToken');
    if (!ssoToken) {
        // Redirect to SSO login
        const currentUrl = window.location.href;
        const ssoUrl = `https://sso.ikameglobal.com/?urlDirect=${encodeURIComponent(currentUrl)}`;
        window.location.href = ssoUrl;
        return false;
    }
    return true;
}

// Handle callback from SSO
function handleSSOCallback() {
    // Read ssoToken from hash fragment
    const hashParams = new URLSearchParams(window.location.hash.substring(1));
    const ssoToken = hashParams.get('ssoToken');
    
    if (ssoToken) {
        // Store ssoToken
        localStorage.setItem('authToken', ssoToken);
        
        // Remove hash fragment from URL
        window.history.replaceState({}, '', window.location.pathname + window.location.search);
        
        // Continue with authenticated flow
        loadUserData(ssoToken);
    }
}

// Call on page load
if (window.location.hash.includes('ssoToken=')) {
    handleSSOCallback();
} else if (!checkAuth()) {
    // Will redirect to SSO
}

PHP (Server-side)

Lưu ý: Hash fragment không được gửi lên server. Bạn cần dùng JavaScript để đọc token từ hash fragment và gửi lên server qua AJAX hoặc form POST.
PHP + JavaScript
<?php
// Check authentication
session_start();

function checkAuth() {
    if (!isset($_SESSION['auth_token'])) {
        // Redirect to SSO login
        $currentUrl = (isset($_SERVER['HTTPS']) ? "https" : "http") . 
                      "://" . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
        $ssoUrl = "https://sso.ikameglobal.com/?urlDirect=" . urlencode($currentUrl);
        header("Location: " . $ssoUrl);
        exit();
    }
    return true;
}

// Handle SSO callback via AJAX (called from JavaScript)
if (isset($_POST['ssoToken'])) {
    $ssoToken = $_POST['ssoToken'];
    
    // Validate ssoToken (call to your auth service or Keycloak)
    if (validateToken($ssoToken)) {
        $_SESSION['auth_token'] = $ssoToken;
        echo json_encode(['success' => true]);
        exit();
    } else {
        echo json_encode(['success' => false, 'error' => 'Invalid token']);
        exit();
    }
}

function validateToken($ssoToken) {
    // TODO: Implement ssoToken validation
    // This should call Keycloak or your auth service to verify the ssoToken
    return true; // Placeholder
}

// Use in your protected pages
checkAuth();
?>

<script>
// Read ssoToken from hash fragment and send to server
(function() {
    const hashParams = new URLSearchParams(window.location.hash.substring(1));
    const ssoToken = hashParams.get('ssoToken');
    
    if (ssoToken) {
        // Remove hash from URL
        window.history.replaceState({}, '', window.location.pathname + window.location.search);
        
        // Send token to server via AJAX
        fetch('', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: 'ssoToken=' + encodeURIComponent(ssoToken)
        }).then(response => response.json())
        .then(data => {
            if (data.success) {
                // Reload page to continue with authenticated session
                window.location.reload();
            } else {
                // Invalid token, redirect to login
                window.location.href = '/login';
            }
        });
    }
})();
</script>

Python (Flask)

Lưu ý: Hash fragment không được gửi lên server. Bạn cần dùng JavaScript để đọc token từ hash fragment và gửi lên server qua AJAX hoặc form POST.
Python + JavaScript
from flask import Flask, redirect, request, session, url_for, render_template, jsonify
from urllib.parse import urlencode
import requests

app = Flask(__name__)
app.secret_key = 'your-secret-key'

SSO_URL = 'https://sso.ikameglobal.com'

def check_auth():
    """Check if user is authenticated"""
    if 'auth_token' not in session:
        # Redirect to SSO login
        current_url = request.url
        sso_url = f"{SSO_URL}/?urlDirect={urlencode({'urlDirect': current_url})}"
        return redirect(sso_url)
    return None

@app.route('/callback', methods=['POST'])
def sso_callback():
    """Handle SSO callback via AJAX"""
    ssoToken = request.form.get('ssoToken')
    if ssoToken:
        # Validate ssoToken
        if validate_token(ssoToken):
            session['auth_token'] = ssoToken
            return jsonify({'success': True})
        else:
            return jsonify({'success': False, 'error': 'Invalid token'})
    return jsonify({'success': False, 'error': 'No token'})

def validate_token(ssoToken):
    """Validate ssoToken with Keycloak"""
    # TODO: Implement ssoToken validation
    # Call Keycloak introspection endpoint or verify JWT
    return True  # Placeholder

@app.route('/dashboard')
def dashboard():
    """Protected route"""
    auth_check = check_auth()
    if auth_check:
        return auth_check
    return render_template('dashboard.html')

if __name__ == '__main__':
    app.run(debug=True)
JavaScript (in dashboard.html)
// Read ssoToken from hash fragment and send to server
(function() {
    const hashParams = new URLSearchParams(window.location.hash.substring(1));
    const ssoToken = hashParams.get('ssoToken');
    
    if (ssoToken) {
        // Remove hash from URL
        window.history.replaceState({}, '', window.location.pathname + window.location.search);
        
        // Send token to server via AJAX
        fetch('/callback', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: 'ssoToken=' + encodeURIComponent(ssoToken)
        }).then(response => response.json())
        .then(data => {
            if (data.success) {
                // Reload page to continue with authenticated session
                window.location.reload();
            } else {
                // Invalid token, redirect to login
                window.location.href = '/login';
            }
        });
    }
})();

Node.js / Express

Lưu ý: Hash fragment không được gửi lên server. Bạn cần dùng JavaScript để đọc token từ hash fragment và gửi lên server qua AJAX hoặc form POST.
JavaScript (Server)
const express = require('express');
const session = require('express-session');
const app = express();

const SSO_URL = 'https://sso.ikameglobal.com';

app.use(session({
    secret: 'your-secret-key',
    resave: false,
    saveUninitialized: false
}));

// Middleware to check authentication
function checkAuth(req, res, next) {
    if (!req.session.authToken) {
        const currentUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
        const ssoUrl = `${SSO_URL}/?urlDirect=${encodeURIComponent(currentUrl)}`;
        return res.redirect(ssoUrl);
    }
    next();
}

// Handle SSO callback via POST (called from client-side JavaScript)
app.post('/callback', express.urlencoded({ extended: true }), (req, res) => {
    const ssoToken = req.body.ssoToken;
    if (ssoToken) {
        // Validate ssoToken
        if (validateToken(ssoToken)) {
            req.session.authToken = ssoToken;
            return res.json({ success: true });
        }
    }
    res.json({ success: false, error: 'Invalid token' });
});

function validateToken(ssoToken) {
    // TODO: Implement ssoToken validation
    // Call Keycloak or verify JWT
    return true; // Placeholder
}

// Protected route
app.get('/dashboard', checkAuth, (req, res) => {
    res.send(`
        <html>
        <head><title>Dashboard</title></head>
        <body>
            <h1>Welcome to Dashboard!</h1>
            <script>
                // Read ssoToken from hash fragment and send to server
                (function() {
                    const hashParams = new URLSearchParams(window.location.hash.substring(1));
                    const ssoToken = hashParams.get('ssoToken');
                    
                    if (ssoToken) {
                        // Remove hash from URL
                        window.history.replaceState({}, '', window.location.pathname + window.location.search);
                        
                        // Send token to server via AJAX
                        fetch('/callback', {
                            method: 'POST',
                            headers: {
                                'Content-Type': 'application/x-www-form-urlencoded',
                            },
                            body: 'ssoToken=' + encodeURIComponent(ssoToken)
                        }).then(response => response.json())
                        .then(data => {
                            if (data.success) {
                                // Reload page to continue with authenticated session
                                window.location.reload();
                            } else {
                                // Invalid token, redirect to login
                                window.location.href = '/login';
                            }
                        });
                    }
                })();
            </script>
        </body>
        </html>
    `);
});

app.listen(3000, () => {
    console.log('Server running on port 3000');
});

React

JavaScript
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';

const SSO_URL = 'https://sso.ikameglobal.com';

function App() {
    const [authenticated, setAuthenticated] = useState(false);
    const navigate = useNavigate();

    useEffect(() => {
        // Handle SSO callback - read from hash fragment
        const hash = window.location.hash.substring(1);
        const hashParams = new URLSearchParams(hash);
        const ssoToken = hashParams.get('ssoToken');
        
        if (ssoToken) {
            localStorage.setItem('authToken', ssoToken);
            setAuthenticated(true);
            // Remove hash fragment from URL
            navigate(window.location.pathname + window.location.search, { replace: true });
        } else {
            // Check existing ssoToken
            const storedToken = localStorage.getItem('authToken');
            if (storedToken) {
                setAuthenticated(true);
            } else {
                // Redirect to SSO
                const currentUrl = window.location.href;
                const ssoUrl = `${SSO_URL}/?urlDirect=${encodeURIComponent(currentUrl)}`;
                window.location.href = ssoUrl;
            }
        }
    }, [navigate]);

    if (!authenticated) {
        return 
Redirecting to login...
; } return (

Welcome!

); } export default App;

Xử lý Token

Token được trả về từ SSO là một JWT (JSON Web Token) access token. Dưới đây là cách xử lý token:

Token Format

Token là một JWT có cấu trúc: header.payload.signature

JWT Structure
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ...
[header].[payload].[signature]

Validate Token

Bạn nên validate token trên server-side để đảm bảo bảo mật. Có 2 cách chính:

1. Token Introspection (Recommended)

Gọi Keycloak introspection endpoint để verify token:

cURL
curl -X POST \
  https://keycloak.ikameglobal.com/auth/realms/ikame-platform/protocol/openid-connect/token/introspect \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'client_id=your-client-id&client_secret=your-secret&token=YOUR_SSO_TOKEN'

2. Verify JWT Signature

Decode và verify JWT signature với Keycloak public key:

Node.js
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const client = jwksClient({
    jwksUri: 'https://keycloak.ikameglobal.com/auth/realms/ikame-platform/protocol/openid-connect/certs'
});

function getKey(header, callback) {
    client.getSigningKey(header.kid, (err, key) => {
        const signingKey = key.publicKey || key.rsaPublicKey;
        callback(null, signingKey);
    });
}

function verifyToken(ssoToken) {
    return new Promise((resolve, reject) => {
        jwt.verify(ssoToken, getKey, {
            audience: 'ikame-sso',
            issuer: 'https://keycloak.ikameglobal.com/auth/realms/ikame-platform'
        }, (err, decoded) => {
            if (err) {
                reject(err);
            } else {
                resolve(decoded);
            }
        });
    });
}

Token Expiration

Token có thời gian hết hạn. Bạn cần kiểm tra expiration time trong token payload và handle token refresh nếu cần.

Decode Token (Client-side only)

Để decode token trên client-side (chỉ để xem thông tin, không verify):

JavaScript
function decodeToken(ssoToken) {
    const base64Url = ssoToken.split('.')[1];
    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
    const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
        return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
    }).join(''));
    return JSON.parse(jsonPayload);
}

const ssoToken = localStorage.getItem('authToken');
const decoded = decodeToken(ssoToken);
console.log('SSO Token expires at:', new Date(decoded.exp * 1000));
Cảnh báo: Chỉ decode token trên client-side để xem thông tin. Luôn validate token trên server-side để đảm bảo bảo mật.

Xử lý lỗi

Dưới đây là các lỗi thường gặp và cách xử lý:

Thiếu urlDirect parameter

Nếu không có urlDirect trong query parameter, SSO sẽ hiển thị error message và không thể redirect về service.

Giải pháp: Luôn đảm bảo include urlDirect parameter khi redirect đến SSO: https://sso.ikameglobal.com/?urlDirect={your_url}

Không đọc được token từ hash fragment

Token được truyền qua hash fragment, không phải query parameter. Bạn cần đọc bằng JavaScript:

JavaScript
// Correct way to read token from hash fragment
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const ssoToken = hashParams.get('ssoToken');

// Wrong way (will not work):
// const urlParams = new URLSearchParams(window.location.search);
// const ssoToken = urlParams.get('ssoToken');

Login failed

User có thể cancel login hoặc có lỗi trong quá trình xác thực.

JavaScript
// Check for error in callback URL
const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get('error');

if (error) {
    console.error('SSO Error:', error);
    // Handle error (show message, redirect to login page, etc.)
    alert('Login failed. Please try again.');
}

Invalid token

Token có thể không hợp lệ hoặc đã hết hạn.

JavaScript
async function validateToken(ssoToken) {
    try {
        const response = await fetch('https://keycloak.ikameglobal.com/auth/realms/ikame-platform/protocol/openid-connect/token/introspect', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                client_id: 'your-client-id',
                client_secret: 'your-secret',
                token: ssoToken
            })
        });
        
        const data = await response.json();
        
        if (data.active) {
            return true; // SSO Token is valid
        } else {
            // SSO Token is invalid or expired
            localStorage.removeItem('authToken');
            return false;
        }
    } catch (error) {
        console.error('SSO Token validation error:', error);
        return false;
    }
}

Network errors

Xử lý các lỗi network khi gọi API:

JavaScript
try {
    const ssoToken = localStorage.getItem('authToken');
    const response = await fetch('/api/protected-endpoint', {
        headers: {
            'Authorization': `Bearer ${ssoToken}`
        }
    });
    
    if (!response.ok) {
        if (response.status === 401) {
            // SSO Token expired or invalid, redirect to SSO
            localStorage.removeItem('authToken');
            redirectToSSO();
        }
        throw new Error('Request failed');
    }
} catch (error) {
    if (error.name === 'TypeError' && error.message.includes('fetch')) {
        // Network error
        console.error('Network error:', error);
        alert('Network error. Please check your connection.');
    }
}

Security Best Practices

Các best practices để đảm bảo bảo mật khi tích hợp SSO:

1. Validate token trên server-side

Luôn validate token trên server-side. Không bao giờ chỉ dựa vào client-side validation.

2. Sử dụng HTTPS only

Đảm bảo tất cả communication đều qua HTTPS để bảo vệ token khỏi man-in-the-middle attacks.

3. Token storage

  • Client-side: Sử dụng localStorage hoặc sessionStorage. Không lưu trong cookies không secure.
  • Server-side: Lưu trong session hoặc database, không expose trong logs.
  • Không lưu token trong URL sau khi đã nhận được (remove từ URL ngay sau khi lưu).

4. CORS considerations

Nếu service của bạn cần gọi API từ browser, đảm bảo CORS được cấu hình đúng trên server.

5. Handle token expiration

Luôn kiểm tra token expiration và redirect user đến SSO để refresh token khi cần.

6. Logout handling

Khi user logout, clear token và có thể gọi Keycloak logout endpoint để invalidate session:

JavaScript
function logout() {
    // Clear local ssoToken
    localStorage.removeItem('authToken');
    
    // Optional: Call Keycloak logout endpoint to invalidate session
    window.location.href = 'https://keycloak.ikameglobal.com/auth/realms/ikame-platform/protocol/openid-connect/logout?redirect_uri=' + encodeURIComponent(window.location.origin);
}

API Reference

SSO Endpoint

URL https://sso.ikameglobal.com/
Method GET
Description SSO login page endpoint

Query Parameters

Parameter Type Required Description
urlDirect string Yes URL to redirect to after successful login. Must be URL encoded.

Callback Response

After successful login, SSO redirects to urlDirect with token in hash fragment:

Format: {urlDirect}#ssoToken={access_token}
Parameter Type Description
ssoToken string JWT access token for authentication (in hash fragment, not query parameter)
Important: Hash fragment is not sent to the server. You must read the token using JavaScript on the client-side and send it to your server via AJAX or form POST for validation.

Keycloak Endpoints

For token validation and other operations:

  • Token Introspection: https://keycloak.ikameglobal.com/auth/realms/ikame-platform/protocol/openid-connect/token/introspect
  • Public Keys: https://keycloak.ikameglobal.com/auth/realms/ikame-platform/protocol/openid-connect/certs
  • Logout: https://keycloak.ikameglobal.com/auth/realms/ikame-platform/protocol/openid-connect/logout

Troubleshooting

FAQ

Q: Token không hoạt động sau khi nhận được?

A: Đảm bảo bạn đã validate token trên server-side. Token có thể đã hết hạn hoặc không hợp lệ. Kiểm tra token expiration time và validate với Keycloak.

Q: Redirect loop xảy ra?

A: Kiểm tra logic check authentication. Đảm bảo bạn không redirect đến SSO nếu user đã có token hợp lệ. Kiểm tra token validation logic.

Q: CORS errors khi gọi API?

A: Đảm bảo server của bạn cấu hình CORS đúng cách để cho phép requests từ domain của bạn. Kiểm tra CORS headers trên server.

Q: Token bị expose trong URL?

A: Sau khi nhận token từ URL, lưu vào storage và remove token khỏi URL ngay lập tức để tránh expose trong browser history hoặc logs.

Common Issues

  • Token expired: Implement token refresh logic hoặc redirect user đến SSO để login lại.
  • Invalid redirect URL: Đảm bảo urlDirect được encode đúng cách và là valid URL.
  • Session timeout: Keycloak session có thể timeout. User cần login lại.

Support

Nếu bạn gặp vấn đề không được giải quyết ở đây, vui lòng liên hệ support team.