Version: 1.1
Last updated: December 5, 2025
SSO Clients (also called Applications or Integrated Apps) are third-party or internal applications registered in the DTR System. They:
/apps)Key files involved:
resources/views/users/admin/sso-clients/ (index, create, edit, _form)SSOClientController.php, SSOController.php (API v1), AppController.phpApp\Models\Clientresources/views/apps/index.blade.phpPurpose:
Scope:
Out of scope:
Required permissions:
view_sso_clients — view list and details of registered clientsmanage_sso_clients — create, edit, delete, rotate secretsRecommended roles:
Implementation note:
SSOClientController via middleware or gate checksUser opens /apps
↓
apps.index renders Client::all() as cards
↓
User clicks app card (if is_sso = true)
↓
POST /api/v1/sso/login or redirect to AppController::applicationLanding
↓
Server builds encrypted JSON payload:
{
"app_id": client.id,
"client_id": client.client_id,
"client_secret": client.client_secret,
"user_id": Auth::id(),
"email": Auth::user()->email,
"name": Auth::user()->name,
"roles": Auth::user()->getRoleNames(),
"timestamp": now(),
"expiry": now() + 60 minutes
}
↓
Encrypt payload using:
- Key: client.client_secret
- Cipher: config('app.cipher')
- Encrypter instance: Encrypter($client->client_secret, $cipher)
↓
Browser redirected to:
{client.redirect_url}/sso-login?token={encrypted_payload}
↓
Third-party app receives token:
- Decrypts using shared client_secret and cipher
- Validates expiry timestamp
- Validates client_id matches
- Creates or maps user account
- Issues app-specific session/token
↓
User logged in to external app
Server-side (DTR System) — Encryption:
use Illuminate\Encryption\Encrypter;
$client = Client::find($clientId);
$cipher = config('app.cipher'); // 'AES-256-CBC' or 'AES-128-CBC'
$payload = [
'app_id' => $client->id,
'client_id' => $client->client_id,
'client_secret' => $client->client_secret,
'user_id' => auth()->id(),
'email' => auth()->user()->email,
'name' => auth()->user()->name,
'roles' => auth()->user()->getRoleNames(),
'timestamp' => now()->timestamp,
'expiry' => now()->addMinutes(60)->timestamp,
];
$encrypter = new Encrypter(
base64_decode($client->client_secret),
$cipher
);
$encrypted = $encrypter->encrypt(json_encode($payload), false);
// Redirect user with encrypted token
redirect("{$client->redirect_url}/sso-login?token={$encrypted}");
Client-side (External App) — Decryption:
// Example: external app decryption (pseudo-code)
use Illuminate\Encryption\Encrypter;
$token = request('token');
$clientSecret = env('DTR_CLIENT_SECRET'); // Shared secret
$cipher = 'AES-256-CBC'; // Must match DTR config
try {
$encrypter = new Encrypter(base64_decode($clientSecret), $cipher);
$payload = json_decode(
$encrypter->decrypt($token, false),
true
);
// Validate expiry
if ($payload['expiry'] < time()) {
abort(401, 'Token expired');
}
// Validate client_id
if ($payload['client_id'] !== env('DTR_CLIENT_ID')) {
abort(401, 'Invalid client');
}
// Process user login
$user = User::firstOrCreate(
['email' => $payload['email']],
['name' => $payload['name']]
);
auth()->login($user);
return redirect('/dashboard');
} catch (\Exception $e) {
abort(401, 'Invalid token: ' . $e->getMessage());
}
Scenario A: Direct SSO from /apps
Scenario B: Cross-Platform Token Verification (API-based)
GET /api/v1/sso/verify with token from payloadView files:
resources/views/users/admin/sso-clients/index.blade.phpresources/views/users/admin/sso-clients/create.blade.phpresources/views/users/admin/sso-clients/edit.blade.phpresources/views/users/admin/sso-clients/_form.blade.phpPurpose: Display all registered SSO clients with quick actions.
UI elements:
Controller method: SSOClientController::index()
public function index()
{
return view('users.admin.sso-clients.index', [
'clients' => Client::paginate(10),
]);
}
Purpose: Register a new SSO client.
Form fields (from _form.blade.php):
On submit (store method):
Model::creating hook auto-generates:
client_id — 32-character random string (unique identifier)client_secret — 32-character random string (encryption key)client_secret for one-time displayImportant: Admin must copy and securely share the client_secret with the external app team. It is never shown again unless regenerated.
Controller method:
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'redirect_url' => 'required|url',
'icon' => 'nullable|image|mimes:png,jpg,jpeg|max:2048',
]);
$client = new Client([
'name' => $validated['name'],
'redirect_url' => $validated['redirect_url'],
]);
$client->save(); // Triggers creating() to generate client_id & client_secret
if ($request->hasFile('icon')) {
$file = $request->file('icon');
$path = $file->storeAs('client_icons', $file->getClientOriginalName(), 'public');
$client->icon()->create([
'path' => asset('storage/' . $path),
'size' => $file->getSize(),
'type' => 'icon',
'extension' => $file->getClientOriginalExtension(),
'name' => $file->getClientOriginalName(),
]);
}
return redirect()->route('admin.sso-clients.index')
->with('message', 'Client created successfully')
->with('client_secret', $client->client_secret); // Flash for display
}
Purpose: Update client metadata (name, redirect URL, icon).
Editable fields:
Non-editable fields:
Controller method:
public function edit(string $id)
{
$client = Client::findOrFail($id);
return view('users.admin.sso-clients.edit', compact('client'));
}
public function update(Request $request, string $id)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'redirect_url' => 'required|url',
'icon' => 'nullable|image|mimes:png,jpg,jpeg|max:2048',
]);
$client = Client::findOrFail($id);
$client->update([
'name' => $validated['name'],
'redirect_url' => $validated['redirect_url'],
]);
if ($request->hasFile('icon')) {
$file = $request->file('icon');
$path = $file->storeAs('client_icons', $file->getClientOriginalName(), 'public');
$client->icon()->create([
'path' => asset('storage/' . $path),
'size' => $file->getSize(),
'type' => 'icon',
'extension' => $file->getClientOriginalExtension(),
'name' => $file->getClientOriginalName(),
]);
}
return redirect()->route('admin.sso-clients.index')
->with('message', 'Client updated successfully');
}
Purpose: Issue a new client_secret when old secret is compromised or rotation policy requires.
Behavior:
Controller method:
public function regenerateSecret(string $id)
{
$client = Client::findOrFail($id);
$newSecret = Str::random(32);
$client->client_secret = $newSecret;
$client->save();
// TODO: Audit log this action
// TODO: Notify external app team
return redirect()->back()
->with('message', 'Secret key regenerated!')
->with('client_secret', $newSecret); // Flash for display
}
Best practice: After regenerate, external app team must update their .env / config with new secret before next SSO attempt.
Purpose: Enable or disable SSO for a specific client without deleting the record.
Behavior:
is_sso flag (boolean)redirect_url without encrypted tokenController method:
public function toggleSSO(Request $request, string $id)
{
$client = Client::findOrFail($id);
$client->is_sso = $request->has('is_sso');
$client->save();
return redirect()->back()->with('message', 'SSO status updated.');
}
Client model fields:
id — Primary key (auto-increment)
name — Display name (string, max 255)
client_id — Unique identifier (string, 32 chars)
client_secret — Encryption key (string, 32 chars)
redirect_url — Post-SSO redirect target (URL)
is_sso — Enable/disable SSO (boolean, default false)
created_at — Timestamp
updated_at — Timestamp
deleted_at — Soft delete timestamp (optional)
Attachment morph (for icon):
id
attachable_type — 'App\Models\Client'
attachable_id — client.id
path — storage URL (asset path)
size — file size in bytes
type — 'icon'
extension — 'png', 'jpg', etc.
name — original filename
created_at
Audit log (recommended):
id
user_id — admin who performed action
resource_type — 'Client'
resource_id — client.id
action — 'create', 'update', 'delete', 'regenerate_secret', 'toggle_sso'
changes — JSON diff (before/after)
ip_address — admin's IP
timestamp — when action occurred
Sample records:
Client 1:
id: 1
name: "HR Portal"
client_id: "abc123...xyz"
client_secret: "secret456...ijk"
redirect_url: "https://hr.example.com/sso-login"
is_sso: true
Client 2:
id: 2
name: "Finance Dashboard"
client_id: "def789...uvw"
client_secret: "secret789...lmn"
redirect_url: "https://finance.example.com/auth/callback"
is_sso: true
GET /api/admin/sso-clients
page, search, sort{
"data": [
{
"id": 1,
"name": "HR Portal",
"client_id": "abc123...",
"redirect_url": "https://hr.example.com/sso-login",
"is_sso": true,
"icon_url": "https://cdn.../icon.png",
"created_at": "2025-01-01T00:00:00Z"
}
],
"pagination": { "total": 5, "per_page": 10, "current_page": 1 }
}GET /api/admin/sso-clients/{id}
{
"id": 1,
"name": "HR Portal",
"client_id": "abc123...",
"client_secret": "secret456...",
"redirect_url": "https://hr.example.com/sso-login",
"is_sso": true,
"icon_url": "https://cdn.../icon.png",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-15T00:00:00Z"
}POST /api/admin/sso-clients
{
"name": "New App",
"redirect_url": "https://app.example.com/sso",
"is_sso": true,
"icon": "<file upload>"
}PUT /api/admin/sso-clients/{id}
name, redirect_url, is_sso, iconPOST /api/admin/sso-clients/{id}/regenerate-secret
reason: "string"{
"message": "Secret regenerated",
"client_secret": "newsecret123..."
}POST /api/admin/sso-clients/{id}/toggle-sso
is_sso: booleanDELETE /api/admin/sso-clients/{id}
POST /api/v1/sso/login (API\V1\SSOController)
{
"email": "user@example.com",
"password": "secret123",
"client_id": "abc123..."
}{
"token": "eyJhbGc...",
"user": {
"id": 42,
"name": "Jane Doe",
"email": "jane@example.com",
"avatar": "https://cdn.../avatar.png",
"roles": ["user", "admin"]
}
}POST /api/v1/sso/authenticate (API\SSOController)
{
"email": "user@example.com",
"password": "secret123",
"client_id": "abc123..."
}GET /api/v1/sso/verify (API\V1\SSOController)
Authorization: Bearer {token}{
"user": {
"id": 42,
"name": "Jane Doe",
"email": "jane@example.com"
},
"verified": true
}POST /api/v1/sso/logout (API\V1\SSOController)
Authorization: Bearer {token}{
"message": "Logged out successfully"
}Location: app/Http/Controllers/Web/Admin/SSOClientController.php
Methods:
index() — List all clients (paginated)
public function index()
{
return view('users.admin.sso-clients.index', [
'clients' => Client::paginate(10),
]);
}
create() — Show create form
public function create()
{
return view('users.admin.sso-clients.create');
}
store() — Save new client
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'redirect_url' => 'required|url',
'icon' => 'nullable|image|mimes:png,jpg,jpeg|max:2048',
]);
$client = new Client([
'name' => $validated['name'],
'redirect_url' => $validated['redirect_url'],
]);
$client->save();
if ($request->hasFile('icon')) {
$file = $request->file('icon');
$path = $file->storeAs('client_icons', $file->getClientOriginalName(), 'public');
$client->icon()->create([
'path' => asset('storage/' . $path),
'size' => $file->getSize(),
'type' => 'icon',
'extension' => $file->getClientOriginalExtension(),
'name' => $file->getClientOriginalName(),
]);
}
return redirect()->route('admin.sso-clients.index')
->with('message', 'Client created successfully')
->with('client_secret', $client->client_secret);
}
edit($id) — Show edit form
public function edit(string $id)
{
$client = Client::findOrFail($id);
return view('users.admin.sso-clients.edit', compact('client'));
}
update() — Save changes
public function update(Request $request, string $id)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'redirect_url' => 'required|url',
'icon' => 'nullable|image|mimes:png,jpg,jpeg|max:2048',
]);
$client = Client::findOrFail($id);
$client->update([
'name' => $validated['name'],
'redirect_url' => $validated['redirect_url'],
]);
if ($request->hasFile('icon')) {
$file = $request->file('icon');
$path = $file->storeAs('client_icons', $file->getClientOriginalName(), 'public');
$client->icon()->create([
'path' => asset('storage/' . $path),
'size' => $file->getSize(),
'type' => 'icon',
'extension' => $file->getClientOriginalExtension(),
'name' => $file->getClientOriginalName(),
]);
}
return redirect()->route('admin.sso-clients.index')
->with('message', 'Client updated successfully');
}
destroy($id) — Delete client
public function destroy(string $id)
{
$client = Client::findOrFail($id);
$client->delete();
return redirect()->route('admin.sso-clients.index')
->with('message', 'Client deleted successfully');
}
regenerateSecret($id) — Generate new client_secret
public function regenerateSecret(string $id)
{
$client = Client::findOrFail($id);
$newSecret = Str::random(32);
$client->client_secret = $newSecret;
$client->save();
return redirect()->back()
->with('message', 'Secret key regenerated!')
->with('client_secret', $newSecret);
}
toggleSSO($id) — Enable/disable SSO
public function toggleSSO(Request $request, string $id)
{
$client = Client::findOrFail($id);
$client->is_sso = $request->has('is_sso');
$client->save();
return redirect()->back()->with('message', 'SSO status updated.');
}
Location: app/Http/Controllers/API/SSOController.php (form-based) and app/Http/Controllers/API/V1/SSOController.php (API-based)
API/SSOController methods:
showLoginForm($request) — Display SSO login form
public function showLoginForm(Request $request)
{
$clientId = $request->query('client_id');
$client = Client::where('client_id', $clientId)->first();
if (!$client) {
abort(401, 'Invalid client');
}
return view('auth.sso-login', [
'clientId' => $clientId,
'redirectUri' => $request->query('redirect_uri'),
'client' => $client
]);
}
login($request) — Authenticate and generate token
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
'client_id' => 'required',
'client_secret' => 'required',
]);
if (!$this->verifyClient($request->client_id, $request->client_secret)) {
return response()->json(['message' => 'Invalid client credentials'], 401);
}
$user = User::with(['profile', 'roles'])->where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
$token = $user->createToken('sso-token')->plainTextToken;
return response()->json(['token' => $token, 'user' => $user]);
}
verify($request) — Check token validity
public function verify(Request $request)
{
return response()->json([
'user' => $request->user()->load('profile'),
'verified' => true
]);
}
logout($request) — Revoke token
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out successfully']);
}
verifyClient($clientId, $clientSecret) — Helper to validate client credentials
private function verifyClient($clientId, $clientSecret = null)
{
return Client::where('client_id', $clientId)
->where('client_secret', $clientSecret)
->exists();
}
API/V1/SSOController methods:
login($request) — OAuth-like login with client_id + redirect_uri
public function login(Request $request)
{
$request->validate([
'client_id' => 'required|string',
'redirect_uri' => 'required|url'
]);
$client = Client::where('client_id', $request->client_id)->firstOrFail();
return view('sso.v1.login', compact('client'));
}
authenticate($request) — Form submission: email/password → token
public function authenticate(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
'client_id' => 'required'
]);
if (!Auth::attempt(['email' => $request->email, 'password' => $request->password])) {
return response()->json(['error' => 'Invalid credentials'], 401);
}
$user = Auth::user();
$token = $user->createToken($request->client_id)->plainTextToken;
return response()->json([
'token' => $token,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar' => $user->profile?->avatar,
'roles' => $user->getRoleNames(),
]
]);
}
Location: app/Http/Controllers/AppController.php
apps() — Show app marketplace
public function apps()
{
return view('apps.index', [
'apps' => Client::where('is_sso', true)->get()
]);
}
applicationLanding() — Encrypt payload and redirect to external app
public function applicationLanding(Request $request, $id)
{
$client = Client::findOrFail($id);
if (!auth()->check()) {
return redirect()->route('login');
}
// Build payload
$payload = [
'app_id' => $client->id,
'client_id' => $client->client_id,
'client_secret' => $client->client_secret,
'user_id' => auth()->id(),
'email' => auth()->user()->email,
'name' => auth()->user()->name,
'roles' => auth()->user()->getRoleNames(),
'timestamp' => now()->timestamp,
'expiry' => now()->addMinutes(60)->timestamp,
];
// Encrypt
$cipher = config('app.cipher');
$encrypter = new Encrypter(
base64_decode($client->client_secret),
$cipher
);
$encrypted = $encrypter->encrypt(json_encode($payload), false);
// Redirect
return redirect("{$client->redirect_url}?token={$encrypted}");
}
Critical security measures:
Client Secret Protection
Encryption Standards
HTTPS / TLS
Token Validation (Client-side responsibility)
Access Control
manage_sso_clients permission to admin/integration rolesauthorize($this->viewClient())) for edit/deleteAudit & Logging
Rate Limiting
/sso-login endpoint (e.g., 10 attempts/min per IP)Revocation & Disable
Security anti-patterns to avoid:
For external app developers integrating with DTR SSO:
Setup Steps:
client_id (32 chars, public)client_secret (32 chars, confidential — store in .env only).env:
DTR_CLIENT_ID=abc123...xyz
DTR_CLIENT_SECRET=secret456...ijk
DTR_SSO_CIPHER=AES-256-CBCOn SSO Redirect: Your app receives request:
GET https://yourapp.example.com/sso-login?token={encrypted_payload}
Decryption (Laravel):
use Illuminate\Encryption\Encrypter;
$token = request('token');
$cipher = env('DTR_SSO_CIPHER', 'AES-256-CBC');
$key = base64_decode(env('DTR_CLIENT_SECRET'));
try {
$encrypter = new Encrypter($key, $cipher);
$payload = json_decode(
$encrypter->decrypt($token, false),
true
);
} catch (\Exception $e) {
return abort(401, 'Invalid token');
}
Decryption (Node.js):
const crypto = require("crypto");
const token = req.query.token;
const secret = process.env.DTR_CLIENT_SECRET;
const cipher = "aes-256-cbc"; // or 'aes-128-cbc'
try {
const decipher = crypto.createDecipher(cipher, secret);
let decrypted = decipher.update(token, "hex", "utf8");
decrypted += decipher.final("utf8");
const payload = JSON.parse(decrypted);
// Validate and process payload
} catch (err) {
return res.status(401).send("Invalid token");
}
Validation:
// Validate expiry
if ($payload['expiry'] < time()) {
abort(401, 'Token expired');
}
// Validate client_id
if ($payload['client_id'] !== env('DTR_CLIENT_ID')) {
abort(401, 'Token mismatch');
}
// Process user
$user = User::firstOrCreate(
['email' => $payload['email']],
[
'name' => $payload['name'],
'external_id' => $payload['user_id'],
'roles' => $payload['roles'],
]
);
auth()->login($user);
return redirect('/dashboard');
Scenario: You want to integrate "HR Portal" app with SSO.
Steps:
Admin navigates to Workplaces → Integrations → SSO Clients
/admin/sso-clientsClicks "Create SSO Client" button
Fills form:
Clicks "Create"
fc3a9d2e...) and client_secret (e.g., 8f2b1c4e...)Admin copies displayed secret
Admin sends HR portal team:
Client ID: fc3a9d2e...
Client Secret: 8f2b1c4e...
Cipher: AES-256-CBC
DTR SSO Endpoint: https://dtr.company.com/api/v1/sso/login
Token Redirect URL: https://dtr.company.com/apps/{app_id}/landing
HR Portal team integrates:
.env/sso-login endpointAdmin verifies on /apps page
Scenario: User clicks "HR Portal" app on /apps
Steps:
User opens /apps (DTR system)
User clicks HR Portal card
/apps/1/landing (AppController::applicationLanding)Server-side processing:
{
"app_id": 1,
"client_id": "fc3a9d2e...",
"client_secret": "8f2b1c4e...",
"user_id": 42,
"email": "jane@company.com",
"name": "Jane Doe",
"roles": ["employee", "hr-manager"],
"timestamp": 1735000000,
"expiry": 1735003600
}GET https://hr.company.com/sso-login?token=abc123encrypted...HR Portal receives request
Result: User logged into HR Portal without re-entering credentials ✓
Scenario: Security audit requires secret rotation quarterly.
Steps:
Admin opens SSO Clients index
Finds client row "HR Portal"
Clicks "Regenerate Secret" action
Confirms action
Server generates new secret:
8f2b1c4e... (invalidated)5d9b3a7c... (active)Admin sees flash message + new secret
Admin notifies HR Portal team:
HR Portal team updates .env and redeploys
Next SSO attempt works with new secret ✓
Scenario: Finance app undergoes maintenance; temporarily hide from /apps.
Steps:
Admin opens SSO Clients index
Finds "Finance Dashboard" row
Clicks toggle "SSO Enabled" → OFF
Server updates: is_sso = false
On /apps:
redirect_url without tokenAfter maintenance, admin clicks toggle → ON
is_sso = trueUnit tests:
Integration tests:
E2E tests (manual):
Security tests:
/sso-login (10 attempts/min/IP)Performance tests:
Q: "SSO redirects but external app shows 'Invalid token'"
Q: "Client not appearing on /apps"
is_sso = true flag is setQ: "Regenerate Secret message says 'success' but external app still fails"
.env with new secret and restartQ: "Token contains user email/roles — PII exposure?"
Q: "How to revoke all active SSO sessions?"
is_sso = false → future tokens not generatedQ: "Can I use same client_secret for multiple apps?"
SQL: Find all active SSO clients
SELECT id, name, client_id, redirect_url, is_sso, created_at
FROM clients
WHERE is_sso = 1 AND deleted_at IS NULL
ORDER BY created_at DESC;
SQL: Audit client modifications (if audit table exists)
SELECT user_id, resource_type, action, changes, ip_address, timestamp
FROM audits
WHERE resource_type = 'Client' AND action IN ('create', 'update', 'regenerate_secret')
ORDER BY timestamp DESC
LIMIT 20;
Laravel Model Hook: Auto-generate secrets
// In Client model
protected static function booted()
{
static::creating(function ($model) {
$model->client_id = Str::random(32);
$model->client_secret = Str::random(32);
});
}
Laravel Route: SSO landing
Route::get('/apps/{id}/landing', [AppController::class, 'applicationLanding'])
->middleware('auth')
->name('app.landing');
Blade template: App card with SSO button
@foreach($apps as $app)
<div class="app-card">
<img src="{{ $app->icon_url }}" alt="{{ $app->name }}">
<h3>{{ $app->name }}</h3>
<p>{{ $app->description ?? 'Integrated Application' }}</p>
@if($app->is_sso)
<form action="{{ route('app.landing', $app->id) }}" method="POST">
@csrf
<button type="submit" class="btn btn-primary">Login with SSO</button>
</form>
@else
<a href="{{ $app->redirect_url }}" target="_blank" class="btn btn-secondary">
Open App
</a>
@endif
</div>
@endforeach
Document version: 1.1
Maintainers: Security / Integration / Product / Engineering
Last updated: December 5, 2025