فرض کنید یک مجموعه نرمافزاری چندین سرویس مختلف دارد:
app.example.com
panel.example.com
shop.example.com
اگر هر سرویس سیستم Login مستقل داشته باشد، کاربر مجبور است برای هر برنامه حساب جداگانه داشته باشد یا حداقل فرآیند احراز هویت در چند نقطه تکرار شود.
راهکار استاندارد برای چنین معماریای استفاده از Single Sign-On یا SSO است.
در مقالههای قبلی با OAuth 2.0، ساخت Authorization Server و سپس ساخت OpenID Connect Provider آشنا شدیم.
حالا یک مرحله جلوتر میرویم و سمت دیگر این معماری را میسازیم:
> OpenID Connect Client
در این مقاله یک Client در PHP طراحی میکنیم که بتواند:
- کاربر را به Provider منتقل کند
- Authorization Code دریافت کند
- PKCE را اجرا کند
- State را بررسی کند
- Nonce را مدیریت کند
- Authorization Code را به Token تبدیل کند
- ID Token را اعتبارسنجی کند
- JWKS Provider را دریافت کند
- اطلاعات کاربر را از UserInfo بگیرد
- حساب OIDC را به User داخلی متصل کند
- Session محلی ایجاد کند
- Logout را مدیریت کند
در پایان، معماری SSO برای یک پروژه PHP را از دید Client بهصورت کاملتر درک خواهیم کرد.
> نکته مهم: کدهای آموزشی این مقاله برای درک معماری هستند. در Production بهتر است اعتبارسنجی JWT/JOSE، Discovery و سایر اجزای حساس با کتابخانههای استاندارد و تستشده انجام شوند و رمزنگاری بهصورت دستی پیادهسازی نشود.
# SSO چیست؟
SSO مخفف:
Single Sign-On
است.
یعنی کاربر یکبار در سیستم مرکزی Authentication وارد میشود و میتواند به چند Application مختلف دسترسی داشته باشد.
مثلاً:
┌─────────────────────┐
│ OpenID Provider │
│ auth.example.com │
└──────────┬──────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
App A App B App C
هر Application یک OIDC Client است.
# Provider و Client چه تفاوتی دارند؟
در معماری OIDC:
Provider
مسئول Authentication و صدور Token است.
مثلاً:
auth.example.com
Client
برنامهای است که میخواهد کاربر را Authenticate کند.
مثلاً:
app.example.com
پس:
Provider
│
│ Authentication
▼
Client
# جریان کلی Login
فرآیند Login با OIDC معمولاً چنین است:
User
│
▼
Application
│
│ Redirect
▼
OIDC Provider
│
│ Login
▼
Authorization Code
│
▼
Application Callback
│
│ Token Request
▼
Token Endpoint
│
▼
ID Token + Access Token
│
▼
Validate ID Token
│
▼
Find/Create Local User
│
▼
Create Local Session
نکته مهم این است که Password کاربر در اختیار Application قرار نمیگیرد.
کاربر فقط در Provider احراز هویت میشود.
# مثال واقعی
فرض کنید:
Provider:
https://auth.example.com
Client:
https://app.example.com
کاربر وارد:
https://app.example.com/login
میشود.
Application کاربر را به:
https://auth.example.com/oauth/authorize
میفرستد.
Provider Login را انجام میدهد.
سپس کاربر به:
https://app.example.com/oauth/callback
برمیگردد.
# ساختار پروژه Client
یک ساختار ساده:
oidc-client/
│
├── config/
│ └── oidc.php
│
├── src/
│ ├── OidcClient.php
│ ├── TokenValidator.php
│ └── UserMapper.php
│
├── public/
│ ├── login.php
│ ├── callback.php
│ ├── logout.php
│ └── index.php
│
└── vendor/
# Configuration
یک فایل:
config/oidc.php
ایجاد میکنیم:
<?php
return [
'issuer' => 'https://auth.example.com',
'client_id' => 'app-client',
'client_secret' => 'YOUR_CLIENT_SECRET',
'redirect_uri' =>
'https://app.example.com/oauth/callback',
'scope' =>
'openid profile email',
];
در Production نباید Secret را مستقیماً داخل Repository قرار دهیم.
بهتر است از Environment Variable استفاده کنیم:
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=app-client
OIDC_CLIENT_SECRET=...
OIDC_REDIRECT_URI=https://app.example.com/oauth/callback
# Discovery
اولین کار Client این است که اطلاعات Provider را از Discovery دریافت کند.
آدرس:
https://auth.example.com/.well-known/openid-configuration
ممکن است Response چیزی شبیه این باشد:
{
"issuer": "https://auth.example.com",
"authorization_endpoint":
"https://auth.example.com/oauth/authorize",
"token_endpoint":
"https://auth.example.com/oauth/token",
"userinfo_endpoint":
"https://auth.example.com/oidc/userinfo",
"jwks_uri":
"https://auth.example.com/.well-known/jwks.json"
}
Client با این اطلاعات میفهمد Endpointهای Provider کجا هستند.
# چرا Discovery مهم است؟
بدون Discovery مجبور بودیم Endpointها را بهصورت دستی وارد Configuration کنیم:
authorization_endpoint
token_endpoint
userinfo_endpoint
jwks_uri
اما با Discovery فقط Issuer را میدانیم:
https://auth.example.com
و بقیه اطلاعات را از Provider دریافت میکنیم.
# دریافت Discovery با PHP
برای نمونه آموزشی:
<?php
$issuer = 'https://auth.example.com';
$url = rtrim($issuer, '/') .
'/.well-known/openid-configuration';
$response = file_get_contents($url);
$config = json_decode(
$response,
true,
512,
JSON_THROW_ON_ERROR
);
var_dump($config);
در Production بهتر است از HTTP Client مناسب با:
- Timeout
- TLS validation
- Error handling
- Retry policy مناسب
استفاده شود.
# بررسی Issuer
یکی از نکات مهم این است که Client نباید هر Discovery Response را قبول کند.
مثلاً:
if ($config['issuer'] !== $issuer) {
throw new RuntimeException(
'Invalid issuer'
);
}
این بررسی باعث میشود Provider مورد انتظار با Provider دیگری اشتباه نشود.
# ساخت Login URL
حالا باید کاربر را به Authorization Endpoint بفرستیم.
برای این کار چند مقدار تصادفی تولید میکنیم.
# ساخت State
$state = bin2hex(random_bytes(32));
State باید در Session محلی ذخیره شود:
$_SESSION['oidc_state'] = $state;
چرا؟
چون هنگام Callback باید بررسی کنیم State برگشتی همان State درخواست اولیه است.
# ساخت Nonce
$nonce = bin2hex(random_bytes(32));
$_SESSION['oidc_nonce'] = $nonce;
Nonce در OIDC برای اتصال ID Token به Authentication Request استفاده میشود.
# ساخت PKCE
ابتدا:
$codeVerifier = rtrim(
strtr(
base64_encode(random_bytes(32)),
'+/',
'-_'
),
'='
);
سپس:
$codeChallenge = rtrim(
strtr(
base64_encode(
hash(
'sha256',
$codeVerifier,
true
)
),
'+/',
'-_'
),
'='
);
و:
$_SESSION['oidc_code_verifier']
= $codeVerifier;
# ساخت Authorization URL
پارامترهای اصلی:
$params = [
'response_type' => 'code',
'client_id' => $config['client_id'],
'redirect_uri' =>
$config['redirect_uri'],
'scope' =>
'openid profile email',
'state' => $state,
'nonce' => $nonce,
'code_challenge' =>
$codeChallenge,
'code_challenge_method' =>
'S256',
];
سپس:
$url =
$provider['authorization_endpoint']
. '?'
. http_build_query($params);
و:
header('Location: ' . $url);
exit;
# Login.php
یک نسخه ساده:
<?php
session_start();
$config = require __DIR__ .
'/../config/oidc.php';
$discoveryUrl =
rtrim($config['issuer'], '/') .
'/.well-known/openid-configuration';
$provider = json_decode(
file_get_contents($discoveryUrl),
true,
512,
JSON_THROW_ON_ERROR
);
$state = bin2hex(
random_bytes(32)
);
$nonce = bin2hex(
random_bytes(32)
);
$codeVerifier = rtrim(
strtr(
base64_encode(
random_bytes(32)
),
'+/',
'-_'
),
'='
);
$codeChallenge = rtrim(
strtr(
base64_encode(
hash(
'sha256',
$codeVerifier,
true
)
),
'+/',
'-_'
),
'='
);
$_SESSION['oidc_state'] = $state;
$_SESSION['oidc_nonce'] = $nonce;
$_SESSION['oidc_code_verifier'] =
$codeVerifier;
$params = [
'response_type' => 'code',
'client_id' => $config['client_id'],
'redirect_uri' =>
$config['redirect_uri'],
'scope' =>
'openid profile email',
'state' => $state,
'nonce' => $nonce,
'code_challenge' =>
$codeChallenge,
'code_challenge_method' =>
'S256',
];
$url =
$provider['authorization_endpoint']
. '?'
. http_build_query($params);
header('Location: ' . $url);
exit;
# کاربر چه چیزی میبیند؟
وقتی کاربر روی Login کلیک میکند:
app.example.com/login
│
▼
auth.example.com/oauth/authorize
در Provider:
┌─────────────────────────────┐
│ ورود به حساب │
│ │
│ ایمیل │
│ [.........................] │
│ │
│ رمز عبور │
│ [.........................] │
│ │
│ ورود │
└─────────────────────────────┘
پسورد فقط در Provider وارد میشود.
# Callback چیست؟
بعد از Authentication، Provider کاربر را به Redirect URI برمیگرداند:
https://app.example.com/oauth/callback
مثلاً:
/oauth/callback?code=ABC123&state=XYZ
حالا مهمترین قسمت Client شروع میشود.
# مرحله اول Callback: بررسی Error
ابتدا بررسی کنیم آیا Provider خطایی ارسال کرده است یا خیر:
if (isset($_GET['error'])) {
throw new RuntimeException(
$_GET['error']
);
}
در برنامه واقعی بهتر است Error را به شکل کنترلشده مدیریت کنیم و مقدار خام آن را مستقیماً به کاربر نمایش ندهیم.
# مرحله دوم: بررسی State
State ذخیرهشده:
$expectedState =
$_SESSION['oidc_state'] ?? null;
State برگشتی:
$receivedState =
$_GET['state'] ?? null;
مقایسه:
if (
!$expectedState ||
!$receivedState ||
!hash_equals(
$expectedState,
$receivedState
)
) {
throw new RuntimeException(
'Invalid state'
);
}
این مرحله بسیار مهم است.
# چرا State مهم است؟
فرض کنید مهاجم تلاش کند Response مربوط به یک Authorization Request دیگر را وارد Session کاربر کند.
State به Client کمک میکند Response را به Request اصلی متصل کند.
به همین دلیل نباید State را حذف کنیم.
# مرحله سوم: دریافت Authorization Code
$code = $_GET['code'] ?? null;
if (!$code) {
throw new RuntimeException(
'Authorization code missing'
);
}
# مرحله چهارم: Token Request
Client حالا Code را به Token Endpoint میفرستد.
مثلاً:
POST /oauth/token
با:
grant_type=authorization_code
client_id=app-client
code=ABC123
redirect_uri=https://app.example.com/oauth/callback
code_verifier=...
# نمونه ارسال Token Request
برای آموزش میتوانیم از cURL استفاده کنیم:
$postData = [
'grant_type' =>
'authorization_code',
'client_id' =>
$config['client_id'],
'client_secret' =>
$config['client_secret'],
'code' =>
$code,
'redirect_uri' =>
$config['redirect_uri'],
'code_verifier' =>
$_SESSION['oidc_code_verifier'],
];
$ch = curl_init(
$provider['token_endpoint']
);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS =>
http_build_query($postData),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'Accept: application/json',
],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
if ($response === false) {
throw new RuntimeException(
curl_error($ch)
);
}
curl_close($ch);
$tokens = json_decode(
$response,
true,
512,
JSON_THROW_ON_ERROR
);
# Token Response
Provider ممکن است چنین Responseای برگرداند:
{
"access_token": "ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "REFRESH_TOKEN",
"id_token": "ID_TOKEN"
}
در اینجا:
access_token
برای Resource Server است.
و:
id_token
برای Authentication است.
# ID Token را فوراً قبول نکنید
یکی از خطرناکترین اشتباهات:
$payload = decodeJwt($tokens['id_token']);
$_SESSION['user'] = $payload;
این کافی نیست.
باید اول:
Signature
Issuer
Audience
Expiration
Nonce
Algorithm
بررسی شوند.
# دریافت JWKS
Client از Discovery مقدار:
jwks_uri
را دریافت کرده است.
مثلاً:
https://auth.example.com/.well-known/jwks.json
حالا:
$jwks = json_decode(
file_get_contents(
$provider['jwks_uri']
),
true,
512,
JSON_THROW_ON_ERROR
);
# پیدا کردن Key بر اساس kid
فرض کنیم Header JWT:
{
"alg": "RS256",
"kid": "key-2026-01"
}
باشد.
Client باید در JWKS دنبال:
kid = key-2026-01
بگردد.
مثلاً:
function findJwk(
array $jwks,
string $kid
): array {
foreach ($jwks['keys'] as $key) {
if (
isset($key['kid']) &&
hash_equals(
$key['kid'],
$kid
)
) {
return $key;
}
}
throw new RuntimeException(
'Signing key not found'
);
}
# اعتبارسنجی Signature
در این قسمت باید JWT با Public Key متناظر اعتبارسنجی شود.
این بخش را نباید با توابع ساده و ناقص خودمان برای Production پیاده کنیم.
یک کتابخانه JOSE/JWT معتبر باید:
JWT
↓
Header
↓
kid
↓
JWKS
↓
Public Key
↓
Signature Verification
را انجام دهد.
# بررسی Issuer
بعد از اعتبارسنجی Signature:
if (
$claims['iss'] !==
$config['issuer']
) {
throw new RuntimeException(
'Invalid issuer'
);
}
# بررسی Audience
باید بررسی کنیم ID Token برای Client خودمان صادر شده است:
if (
$claims['aud'] !==
$config['client_id']
) {
throw new RuntimeException(
'Invalid audience'
);
}
در بعضی پیادهسازیها aud میتواند آرایه باشد؛ بنابراین Validator واقعی باید هر دو حالت استاندارد را بهدرستی مدیریت کند.
# بررسی Expiration
if (
!isset($claims['exp']) ||
$claims['exp'] < time()
) {
throw new RuntimeException(
'ID Token expired'
);
}
در Validator واقعی باید Clock Skew مجاز نیز در طراحی در نظر گرفته شود.
# بررسی Nonce
Nonce اولیه:
$expectedNonce =
$_SESSION['oidc_nonce'];
Nonce داخل ID Token:
$tokenNonce =
$claims['nonce'] ?? null;
مقایسه:
if (
!$tokenNonce ||
!hash_equals(
$expectedNonce,
$tokenNonce
)
) {
throw new RuntimeException(
'Invalid nonce'
);
}
# چرا Nonce اینقدر مهم است؟
چون Client باید مطمئن شود ID Token مربوط به همان Authentication Request است که خودش ایجاد کرده است.
در OIDC:
Authentication Request
│
│ nonce
▼
Provider
│
│ ID Token
▼
Client
│
▼
Compare nonce
# دریافت UserInfo
بعد از دریافت Access Token میتوانیم UserInfo را درخواست کنیم.
$ch = curl_init(
$provider['userinfo_endpoint']
);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' .
$tokens['access_token'],
'Accept: application/json',
],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
curl_close($ch);
$userInfo = json_decode(
$response,
true,
512,
JSON_THROW_ON_ERROR
);
مثلاً:
{
"sub": "user_123",
"name": "Ali",
"email": "user@example.com",
"email_verified": true
}
# ID Token یا UserInfo؟
هر دو میتوانند اطلاعاتی درباره کاربر داشته باشند، اما کاربردشان متفاوت است.
برای Authentication ابتدا ID Token را اعتبارسنجی میکنیم.
سپس در صورت نیاز میتوانیم UserInfo را برای دریافت اطلاعات پروفایل درخواست کنیم.
نباید صرفاً یک Email دریافتی از UserInfo را بدون اعتبارسنجی Token و بدون اتصال صحیح Identity به حساب داخلی معتبر فرض کنیم.
# اتصال کاربر OIDC به کاربر داخلی
فرض کنید در دیتابیس Application جدول:
users
داریم.
و جدول Identity:
CREATE TABLE user_identities (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
issuer VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY unique_oidc_identity (
issuer,
subject
)
);
پس هویت خارجی کاربر را با:
issuer
+
subject
نگهداری میکنیم.
# چرا issuer + subject؟
فرض کنید دو Provider داریم:
https://auth.example.com
https://login.example.org
و هر دو کاربری با:
sub = user_123
دارند.
اگر فقط sub را ذخیره کنیم، ممکن است دو هویت مختلف را اشتباه یکی کنیم.
اما:
issuer + sub
هویت را مشخص میکند.
# فرآیند پیدا کردن User
بهصورت مفهومی:
ID Token
│
├── iss
└── sub
│
▼
user_identities
│
┌────┴────┐
│ │
exists not exists
│ │
▼ ▼
Login Create/Link
# ساخت حساب کاربر
اگر Identity وجود نداشته باشد و سیاست Application اجازه ساخت خودکار حساب بدهد:
OIDC Identity
│
▼
Create User
│
▼
Create user_identity
│
▼
Login
اما بهتر است این رفتار قابل تنظیم باشد.
در برخی سیستمها باید کاربر ابتدا توسط مدیر ایجاد شده باشد.
# اتصال بر اساس Email
گاهی میخواهیم اگر Email کاربر OIDC با Email یک User داخلی برابر بود، حسابها را به هم متصل کنیم.
اما این کار باید با احتیاط انجام شود.
مثلاً صرفاً:
email == email
نباید همیشه به معنای مالکیت قطعی حساب داخلی تلقی شود.
اگر Provider اطلاعات email_verified را ارائه میدهد، باید وضعیت آن نیز بررسی شود و سیاست Linking حساب بهصورت شفاف تعریف شود.
برای سیستمهای حساس، Account Linking بهتر است نیازمند تأیید مجدد کاربر باشد.
# ایجاد Session محلی
بعد از اینکه Identity معتبر شد:
session_regenerate_id(true);
$_SESSION['user_id'] =
$localUser['id'];
$_SESSION['authenticated'] =
true;
حالا Application Session خودش را دارد.
نکته مهم:
OIDC Token
≠
Application Session
# چرا Session محلی بسازیم؟
لازم نیست در هر Request به Provider مراجعه کنیم.
بهتر است:
Login
│
▼
OIDC
│
▼
Local Session
│
▼
Application Requests
داشته باشیم.
در نتیجه کاربر در Application با Session داخلی مدیریت میشود.
# Cookie Session
Session Cookie باید با تنظیمات مناسب امنیتی استفاده شود.
مثلاً:
session_set_cookie_params([
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
در محیط Production و با توجه به معماری دقیق Domainها، مقدار SameSite باید بر اساس نیاز واقعی انتخاب شود.
# پاک کردن اطلاعات موقت OIDC
بعد از Login موفق:
unset(
$_SESSION['oidc_state'],
$_SESSION['oidc_nonce'],
$_SESSION['oidc_code_verifier']
);
اطلاعات موقتی که دیگر مورد نیاز نیستند نباید بیدلیل در Session باقی بمانند.
# Logout محلی
در سادهترین حالت:
<?php
session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params =
session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
$params['secure'],
$params['httponly']
);
}
session_destroy();
header('Location: /');
exit;
این فقط Local Logout است.
# Local Logout با Provider Logout فرق دارد
فرض کنید:
Application
↓
Logout
اما Session کاربر در Provider همچنان فعال است.
اگر کاربر دوباره روی Login کلیک کند:
Application
↓
Provider
↓
Provider Session موجود است
↓
Login بدون فرم
ممکن است دوباره وارد Application شود.
بنابراین در معماری SSO باید از ابتدا مشخص کنیم:
Local Logout
یا:
Provider Logout
یا هر دو مورد را میخواهیم.
# RP-Initiated Logout
OpenID Connect میتواند فرآیند Logout را نیز استاندارد کند.
Client میتواند کاربر را به Logout Endpoint Provider منتقل کند.
پارامترهایی مانند:
id_token_hint
post_logout_redirect_uri
state
ممکن است در این فرآیند استفاده شوند.
اگر Provider از این قابلیت پشتیبانی کند، Application میتواند Logout مرکزی را نیز آغاز کند.
# Post Logout Redirect
مانند Redirect URI در Login، مقصد بعد از Logout نیز باید Allowlist شود.
مثلاً:
https://app.example.com/
نباید اجازه دهیم:
?post_logout_redirect_uri=https://evil.example
بدون اعتبارسنجی پذیرفته شود.
# مدیریت Refresh Token
اگر Client یک Refresh Token دریافت کند، باید آن را مانند یک Credential حساس در نظر بگیریم.
نباید:
در HTML
در URL
در JavaScript
در Log
در LocalStorage بدون بررسی معماری
قرار گیرد.
در یک Server-side PHP Application معمولاً نگهداری آن در سمت Server گزینه مناسبتری نسبت به ارسال آن به Browser است.
# Refresh کردن Access Token
وقتی Access Token منقضی شود:
Access Token
│
▼
Expired
│
▼
Refresh Token
│
▼
Token Endpoint
│
├── New Access Token
└── New Refresh Token
اگر Provider از Refresh Token Rotation استفاده کند، Refresh Token قبلی نیز باید طبق سیاست Provider باطل شود.
# معماری کامل SSO
حالا کل سیستم ما چنین چیزی میشود:
┌───────────────────┐
│ Browser │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ PHP Application │
│ OIDC Client │
└─────────┬─────────┘
│
Authorization
│
▼
┌───────────────────┐
│ OpenID Provider │
│ │
│ Login │
│ Consent │
│ Token │
│ JWKS │
│ UserInfo │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Local Session │
└───────────────────┘
# اگر چند Application داشته باشیم چه؟
فرض کنید:
app.example.com
panel.example.com
shop.example.com
هر سه Client جداگانهای هستند:
Client A
Client B
Client C
اما همه به:
auth.example.com
متصل هستند.
کاربر:
Login → App A
و سپس:
App B → Provider → Session موجود → Login
را تجربه میکند.
# امنیت SSO در چند Application
در سیستم چند Client، هر Client باید:
client_idخودش را داشته باشدredirect_uriخودش را داشته باشد- Scopeهای مورد نیاز خودش را داشته باشد
- Secret خودش را داشته باشد، در صورت confidential بودن
- Session خودش را مدیریت کند
نباید یک Client Secret را بین چند Application مختلف به اشتراک بگذاریم مگر اینکه معماری مشخصاً چنین چیزی را ایجاب کند.
# Public Client و Confidential Client
یکی از مفاهیم مهم OIDC این است که همه Clientها یکسان نیستند.
Confidential Client
مثلاً یک PHP Server-side Application.
این Application میتواند Secret را روی Server نگه دارد.
Public Client
مثل بعضی SPAها یا Native Apps.
این نوع Client نمیتواند Secret را به شکل قابل اعتماد محرمانه نگه دارد.
به همین دلیل PKCE اهمیت بیشتری پیدا میکند.
# چرا PHP Server-side برای OIDC سادهتر است؟
در PHP Server-side:
Browser
│
▼
PHP Server
│
├── Client Secret
├── Code Verifier
├── Access Token
└── Refresh Token
میتواند در سمت Server مدیریت شود.
در نتیجه اطلاعات حساس لازم نیست در Browser نگهداری شوند.
# حملات و اشتباهات رایج
1. حذف State
نباید Authorization Response را بدون بررسی State قبول کنیم.
2. حذف PKCE
حتی اگر Client Secret داریم، PKCE یک لایه امنیتی مهم برای Authorization Code Flow است.
3. عدم بررسی Nonce
در OIDC باید Nonce صحیح مدیریت شود.
4. عدم بررسی Audience
ممکن است Token معتبر باشد اما برای Client دیگری صادر شده باشد.
5. اعتماد به Email بدون سیاست Account Linking
Email بهتنهایی نباید باعث اتصال خودکار دو حساب شود مگر اینکه سیاست و سطح اعتماد Provider مشخص باشد.
6. Decode کردن JWT بدون Verify
این کار به تنهایی Authentication محسوب نمیشود.
7. قبول هر JWKS
JWKS باید از Issuer مورد انتظار دریافت شود و زنجیره اعتماد صحیح باشد.
8. ذخیره Token در URL
Access Token و Refresh Token نباید در URL قرار بگیرند.
9. قرار دادن Token در Log
مثلاً:
Log::info($accessToken);
نباید انجام شود.
10. عدم استفاده از HTTPS
Authentication بدون HTTPS در محیط Production مناسب نیست.
# ساخت یک OIDC Client Class
بعد از اینکه مفهوم را فهمیدیم، بهتر است منطق را از فایلهای PHP جدا کنیم.
مثلاً:
class OidcClient
{
public function __construct(
private array $config
) {}
public function getAuthorizationUrl(): string
{
// Discovery
// State
// Nonce
// PKCE
// URL generation
}
public function exchangeCode(
string $code,
string $codeVerifier
): array {
// Token Request
}
public function validateIdToken(
string $idToken,
string $nonce
): array {
// JWT validation
}
public function getUserInfo(
string $accessToken
): array {
// UserInfo
}
}
این ساختار در پروژههای بزرگ بسیار قابل نگهداریتر است.
# ساختار نهایی پیشنهادی
src/
│
├── Oidc/
│ ├── Discovery.php
│ ├── Authorization.php
│ ├── TokenClient.php
│ ├── IdTokenValidator.php
│ ├── JwksClient.php
│ └── UserInfoClient.php
│
├── Auth/
│ ├── UserMapper.php
│ └── SessionManager.php
│
└── Security/
├── StateManager.php
└── PkceManager.php
این تفکیک باعث میشود هر بخش مسئولیت مشخصی داشته باشد.
# تست فرآیند Login
برای تست کامل:
مرحله اول
به:
/app/login
بروید.
مرحله دوم
بررسی کنید Browser به:
/auth/authorize
منتقل شده است.
مرحله سوم
در Provider Login کنید.
مرحله چهارم
بررسی کنید به:
/app/oauth/callback
برگشتهاید.
مرحله پنجم
Callback باید Code دریافت کند.
مرحله ششم
Code به Token Endpoint ارسال شود.
مرحله هفتم
ID Token اعتبارسنجی شود.
مرحله هشتم
User داخلی پیدا یا ایجاد شود.
مرحله نهم
Local Session ایجاد شود.
# چه چیزهایی را باید در لاگ ثبت کنیم؟
برای Debug میتوانیم اطلاعات غیرحساس را ثبت کنیم:
OIDC login started
OIDC callback received
Client ID
Issuer
Token endpoint status
User internal ID
Authentication success/failure
اما موارد زیر نباید Log شوند:
Password
Access Token
Refresh Token
ID Token کامل
Authorization Code
Client Secret
Private Key
Code Verifier
در محیط Production حتی بعضی از موارد غیرحساس نیز باید با توجه به نیاز واقعی Logging محدود شوند.
# چکلیست Production
قبل از Production کردن OIDC Client:
- [ ] HTTPS فعال است
- [ ] Discovery اعتبارسنجی میشود
- [ ] Issuer بررسی میشود
- [ ] State تصادفی است
- [ ] State در Callback بررسی میشود
- [ ] Nonce تصادفی است
- [ ] Nonce در ID Token بررسی میشود
- [ ] PKCE فعال است
- [ ] Code Verifier بهصورت امن نگهداری میشود
- [ ] Redirect URI دقیق است
- [ ] Authorization Code فقط یکبار مصرف میشود
- [ ] ID Token Signature بررسی میشود
- [ ] Algorithm Allowlist شده
- [ ]
kidاز JWT با JWKS تطبیق داده میشود - [ ] Issuer بررسی میشود
- [ ] Audience بررسی میشود
- [ ] Expiration بررسی میشود
- [ ] Subject پایدار است
- [ ] User Identity با
issuer + subذخیره میشود - [ ] Session بعد از Authentication با
session_regenerate_id()بازسازی میشود - [ ] Tokenها در URL قرار نمیگیرند
- [ ] Tokenها در Log ذخیره نمیشوند
- [ ] Client Secret فقط سمت Server نگهداری میشود
- [ ] Refresh Token امن نگهداری میشود
- [ ] Logout بهدرستی طراحی شده
- [ ] Post Logout Redirect Allowlist شده
- [ ] Rate Limiting برای Login/Callback در نظر گرفته شده
- [ ] کتابخانه JWT/JOSE معتبر استفاده شده است
# جمعبندی
در این مقاله از سمت Provider به سمت Client حرکت کردیم و یک معماری کاملتر برای SSO در PHP ساختیم.
فرآیند اصلی به این شکل است:
User
↓
PHP Application
↓
OIDC Authorization Endpoint
↓
Login
↓
Authorization Code
↓
Callback
↓
Token Endpoint
↓
ID Token + Access Token
↓
Signature Validation
↓
Issuer / Audience / Exp / Nonce
↓
UserInfo
↓
issuer + sub
↓
Local User
↓
Local Session
نکته مهم این است که OIDC فقط یک Redirect ساده برای Login نیست.
برای پیادهسازی صحیح باید چند لایه امنیتی را همزمان در نظر بگیریم:
OAuth 2.0
+
PKCE
+
State
+
Nonce
+
JWT Validation
+
JWKS
+
Session Security
+
Secure Account Linking
=
OIDC Client
با داشتن Provider و Client، حالا معماری SSO ما تقریباً کامل شده است.
در مرحله بعد میتوانیم یک بخش بسیار مهم دیگر را به این زنجیره اضافه کنیم: مدیریت حرفهای کلیدهای JWT و JWKS در PHP؛ شامل ساخت RSA Key Pair، kid، Key Rotation، انتشار چند کلید همزمان در JWKS، اعتبارسنجی کلیدها و نحوه تغییر کلید بدون از کار افتادن Login کاربران.





