Administrator — SSO Clients (Applications) Documentation

Version: 1.1
Last updated: December 5, 2025

Table of Contents


1. Overview

SSO Clients (also called Applications or Integrated Apps) are third-party or internal applications registered in the DTR System. They:

  • Appear as app cards on the user-facing Apps page (/apps)
  • Can authenticate users via Single Sign-On (SSO) when configured
  • Receive encrypted user identity and context tokens for seamless login
  • Are managed entirely through the admin interface

Key files involved:

  • Admin views: resources/views/users/admin/sso-clients/ (index, create, edit, _form)
  • Controllers: SSOClientController.php, SSOController.php (API v1), AppController.php
  • Model: App\Models\Client
  • User-facing view: resources/views/apps/index.blade.php

2. Purpose & Scope

Purpose:

  • Allow administrators to register and manage external/internal applications
  • Enable seamless SSO login for users without re-entering credentials
  • Provide a centralized app marketplace/launcher on the DTR system
  • Track app usage and audit SSO login events

Scope:

  • Admin CRUD for client records (create, read, update, delete)
  • Icon/branding uploads for app cards
  • Secret key rotation and client enable/disable
  • SSO login endpoints and token generation
  • User landing/redirect to external app with encrypted payload

Out of scope:

  • App provisioning automation (documented separately)
  • Advanced OAuth2 / OIDC flows (use standard implementations if needed)
  • User consent / scope management (simplified for internal apps)

3. Access & Permissions

Required permissions:

  • view_sso_clients — view list and details of registered clients
  • manage_sso_clients — create, edit, delete, rotate secrets

Recommended roles:

  • Admin — full CRUD, secret rotation, enable/disable
  • Integration Owner — manage specific clients (role-based filtering)
  • User — view and launch app (on /apps, no admin actions)

Implementation note:

  • Enforce RBAC server-side in SSOClientController via middleware or gate checks
  • UI hides sensitive fields (client_secret, regenerate button) for non-admin roles
  • API should return 403 Forbidden if user lacks permission

4. How SSO Works

4.1 SSO Flow Diagram

User 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

4.2 Token Encryption & Decryption

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());
}

4.3 User Authentication Process

Scenario A: Direct SSO from /apps

  1. User authenticated in DTR system (session exists)
  2. User clicks app card on /apps
  3. System detects is_sso flag and builds encrypted token
  4. User redirected to external app with token
  5. External app decrypts and auto-logs user in

Scenario B: Cross-Platform Token Verification (API-based)

  1. External app calls DTR API: GET /api/v1/sso/verify with token from payload
  2. DTR decrypts token, validates expiry and client_id
  3. Returns user info if valid, or 401 if invalid/expired
  4. External app uses verified user data

5. Admin UI — Managing SSO Clients

View files:

  • resources/views/users/admin/sso-clients/index.blade.php
  • resources/views/users/admin/sso-clients/create.blade.php
  • resources/views/users/admin/sso-clients/edit.blade.php
  • resources/views/users/admin/sso-clients/_form.blade.php

5.1 Index View (List Clients)

Purpose: Display all registered SSO clients with quick actions.

UI elements:

  • Search bar: filter by client name or client_id
  • Columns:
    • Icon (thumbnail)
    • Client Name
    • Client ID (read-only)
    • Redirect URL
    • SSO Enabled (toggle badge)
    • Created At
    • Last Used (audit timestamp)
    • Actions (Edit, View Secret, Regenerate Secret, Delete, Toggle SSO)
  • Pagination: 10 items per page (configurable)
  • Bulk actions: Delete selected, Export selected

Controller method: SSOClientController::index()

public function index()
{
    return view('users.admin.sso-clients.index', [
        'clients' => Client::paginate(10),
    ]);
}

5.2 Create Client

Purpose: Register a new SSO client.

Form fields (from _form.blade.php):

  • Name (required, string, max 255) — display name of the app
  • Redirect URL (required, valid URL) — where user is redirected after SSO token generation
  • Icon (optional, image, max 2MB) — PNG/JPG/JPEG for app card display
  • SSO Enabled (optional, checkbox) — toggle to enable/disable SSO for this client

On submit (store method):

  1. Validate inputs
  2. Create Client record
  3. Model::creating hook auto-generates:
    • client_id — 32-character random string (unique identifier)
    • client_secret — 32-character random string (encryption key)
  4. Upload icon file and create Attachment morph record
  5. Return to index with success message + flash client_secret for one-time display

Important: 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
}

5.3 Edit Client

Purpose: Update client metadata (name, redirect URL, icon).

Editable fields:

  • Name
  • Redirect URL
  • Icon (replace or add if missing)

Non-editable fields:

  • client_id (immutable)
  • client_secret (only via regenerate)
  • is_sso (via separate toggle action)

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');
}

5.4 Regenerate Secret

Purpose: Issue a new client_secret when old secret is compromised or rotation policy requires.

Behavior:

  • Admin clicks "Regenerate Secret" action on index/show
  • System generates new random 32-char secret
  • Old secret becomes invalid immediately
  • New secret flashed to UI for one-time copy
  • Admin must communicate new secret to external app team
  • Old SSO tokens encrypted with old secret become invalid
  • Action is audited: record user, timestamp, IP, reason (optional form field)

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.

5.5 Toggle SSO Status

Purpose: Enable or disable SSO for a specific client without deleting the record.

Behavior:

  • Toggle checkbox or button on edit/index view
  • Update is_sso flag (boolean)
  • If disabled:
    • App still appears on /apps but shows "Direct Link" instead of SSO button
    • Users redirected directly to redirect_url without encrypted token
    • No SSO landing / token generation
  • If enabled:
    • App shows SSO button on /apps
    • Clicking triggers full SSO flow with token

Controller 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.');
}

6. Data Model & Database

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

7. API Endpoints

7.1 Admin API (Client Management)

GET /api/admin/sso-clients

  • Returns paginated list of all clients
  • Query params: page, search, sort
  • Response:
    {
        "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}

  • Returns single client with full details (includes client_secret for admin only)
  • Response:
    {
        "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

  • Create new client
  • Body:
    {
        "name": "New App",
        "redirect_url": "https://app.example.com/sso",
        "is_sso": true,
        "icon": "<file upload>"
    }
  • Response: 201 Created + client record with generated client_id & client_secret

PUT /api/admin/sso-clients/{id}

  • Update client metadata
  • Body: (any of) name, redirect_url, is_sso, icon
  • Response: 200 OK + updated client record

POST /api/admin/sso-clients/{id}/regenerate-secret

  • Regenerate client_secret
  • Body: (optional) reason: "string"
  • Response: 200 OK + new secret flashed
    {
        "message": "Secret regenerated",
        "client_secret": "newsecret123..."
    }

POST /api/admin/sso-clients/{id}/toggle-sso

  • Toggle is_sso flag
  • Body: is_sso: boolean
  • Response: 200 OK

DELETE /api/admin/sso-clients/{id}

  • Soft or hard delete client (audit required)
  • Response: 200 OK + confirmation message

7.2 SSO Login API

POST /api/v1/sso/login (API\V1\SSOController)

  • Authenticate user via email/password and generate SSO token
  • Body:
    {
        "email": "user@example.com",
        "password": "secret123",
        "client_id": "abc123..."
    }
  • Response: 200 OK + token + user info
    {
        "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)

  • Alternative endpoint for form-based auth (returns encrypted payload)
  • Body:
    {
        "email": "user@example.com",
        "password": "secret123",
        "client_id": "abc123..."
    }
  • Response: redirect to client app with encrypted token, or JSON error

7.3 Token Verification & Logout

GET /api/v1/sso/verify (API\V1\SSOController)

  • Verify current token is valid
  • Headers: Authorization: Bearer {token}
  • Response: 200 OK + user info
    {
        "user": {
            "id": 42,
            "name": "Jane Doe",
            "email": "jane@example.com"
        },
        "verified": true
    }

POST /api/v1/sso/logout (API\V1\SSOController)

  • Revoke current token
  • Headers: Authorization: Bearer {token}
  • Response: 200 OK
    {
        "message": "Logged out successfully"
    }

8. Controller Implementation Details

8.1 SSOClientController

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.');
}

8.2 SSOController (API)

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(),
        ]
    ]);
}

8.3 AppController (User SSO Landing)

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}");
}

9. Security Considerations

Critical security measures:

  1. Client Secret Protection

    • Store client_secret in DB; never log or expose in UI except during creation
    • Use database encryption at rest (if sensitive env)
    • Consider storing in external vault (e.g., HashiCorp Vault)
    • Rotate periodically; notify external app team
  2. Encryption Standards

    • Use AES-256-CBC or AES-128-CBC (must match client config)
    • Token expiry: recommend 30–60 minutes (shorter is more secure)
    • Ensure client_secret is 32+ chars (sufficient entropy)
  3. HTTPS / TLS

    • Always use HTTPS for redirect_url
    • Enforce HSTS headers on both DTR and client app
    • Validate certificate pinning if possible on client-side
  4. Token Validation (Client-side responsibility)

    • Decrypt with correct cipher and key
    • Validate expiry timestamp (reject if expired)
    • Validate client_id matches expected
    • Validate timestamp freshness (not old)
  5. Access Control

    • Limit manage_sso_clients permission to admin/integration roles
    • Enforce RBAC checks in controller (middleware or gate)
    • Use policy authorization (authorize($this->viewClient())) for edit/delete
  6. Audit & Logging

    • Log all SSO landing attempts: client_id, user_id, IP, timestamp, status
    • Log secret regenerations with admin who performed action
    • Alert on failed decryption or token validation errors
    • Retain logs for 90+ days for compliance
  7. Rate Limiting

    • Implement rate limiting on /sso-login endpoint (e.g., 10 attempts/min per IP)
    • Prevent brute force attacks on email/password
  8. Revocation & Disable

    • When client disabled, ensure /apps no longer shows it
    • When client deleted, previous tokens should fail (old secret invalid)
    • Support manual revocation list if needed

Security anti-patterns to avoid:

  • ❌ Exposing client_secret in query string (use body or header)
  • ❌ Long token expiry (> 2 hours)
  • ❌ Logging plaintext payloads
  • ❌ Trusting client-side claims without server validation
  • ❌ Reusing client_secret across multiple clients

10. Integration Guide for Third-Party Apps

For external app developers integrating with DTR SSO:

Setup Steps:

  1. Request registration: provide app name, redirect URL, team contact
  2. Admin creates client record → you receive:
    • client_id (32 chars, public)
    • client_secret (32 chars, confidential — store in .env only)
  3. Store in your .env:
    DTR_CLIENT_ID=abc123...xyz
    DTR_CLIENT_SECRET=secret456...ijk
    DTR_SSO_CIPHER=AES-256-CBC

On 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');

11. Practical Workflows & Examples

11.1 Register New SSO Client

Scenario: You want to integrate "HR Portal" app with SSO.

Steps:

  1. Admin navigates to Workplaces → Integrations → SSO Clients

    • Or direct URL: /admin/sso-clients
  2. Clicks "Create SSO Client" button

    • Brought to create form
  3. Fills form:

  4. Clicks "Create"

    • System validates URL format
    • Creates Client record in DB
    • Auto-generates client_id (e.g., fc3a9d2e...) and client_secret (e.g., 8f2b1c4e...)
    • Stores icon attachment
    • Redirects to index with success message + flashed secret
  5. Admin copies displayed secret

    • ⚠️ Secret only shown once; if missed, must regenerate
  6. 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
  7. HR Portal team integrates:

    • Stores credentials in .env
    • Implements token decryption on /sso-login endpoint
    • Tests login flow
  8. Admin verifies on /apps page

    • HR Portal appears as card with logo
    • Click triggers SSO flow

11.2 User SSO Login Flow

Scenario: User clicks "HR Portal" app on /apps

Steps:

  1. User opens /apps (DTR system)

    • Sees app cards: HR Portal, Finance Dashboard, etc.
  2. User clicks HR Portal card

    • Browser navigates to /apps/1/landing (AppController::applicationLanding)
  3. Server-side processing:

    • Checks user is authenticated (redirects to /login if not)
    • Loads Client record (id=1, name="HR Portal", is_sso=true)
    • Builds payload:
      {
          "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
      }
    • Encrypts using client_secret + AES-256-CBC
    • Redirects:
      GET https://hr.company.com/sso-login?token=abc123encrypted...
  4. HR Portal receives request

    • Extracts token from query param
    • Decrypts using stored DTR_CLIENT_SECRET
    • Validates:
      • Expiry: 1735003600 > current time ✓
      • client_id: fc3a9d2e matches expected ✓
    • Creates/updates user in HR Portal DB
    • Issues app-specific session
    • Redirects to dashboard
  5. Result: User logged into HR Portal without re-entering credentials ✓

11.3 Rotate Client Secret

Scenario: Security audit requires secret rotation quarterly.

Steps:

  1. Admin opens SSO Clients index

  2. Finds client row "HR Portal"

  3. Clicks "Regenerate Secret" action

    • Prompted for optional reason: "Quarterly rotation — Q1 2025"
  4. Confirms action

  5. Server generates new secret:

    • Old: 8f2b1c4e... (invalidated)
    • New: 5d9b3a7c... (active)
  6. Admin sees flash message + new secret

  7. Admin notifies HR Portal team:

    • "New client_secret: 5d9b3a7c..."
    • "Old secret expires in 30 days; please update by [date]"
    • Post-deadline, old tokens fail decryption
  8. HR Portal team updates .env and redeploys

  9. Next SSO attempt works with new secret

11.4 Disable / Enable Client

Scenario: Finance app undergoes maintenance; temporarily hide from /apps.

Steps:

  1. Admin opens SSO Clients index

  2. Finds "Finance Dashboard" row

  3. Clicks toggle "SSO Enabled" → OFF

    • Or via edit form, uncheck "SSO Enabled"
  4. Server updates: is_sso = false

  5. On /apps:

    • Finance Dashboard still appears (if is_active flag, else hidden)
    • Button shows "Direct Link" instead of "SSO Login"
    • Click navigates directly to redirect_url without token
  6. After maintenance, admin clicks toggle → ON

    • is_sso = true
    • Button reverts to "SSO Login"
    • Full SSO flow restored ✓

12. Testing & QA Checklist

Unit tests:

  • ✓ Client::create auto-generates client_id (32 chars, unique)
  • ✓ Client::create auto-generates client_secret (32 chars, unique)
  • ✓ Encryption/decryption payload roundtrip
  • ✓ Token expiry validation (reject if past expiry)
  • ✓ client_id validation (reject if mismatch)

Integration tests:

  • ✓ SSOClientController store → Client record created in DB
  • ✓ Icon upload → Attachment morph stored and URL correct
  • ✓ SSOClientController regenerateSecret → new secret issued, old invalid
  • ✓ AppController::applicationLanding → encrypted redirect works end-to-end
  • ✓ External app decryption → payload parsed correctly

E2E tests (manual):

  • ✓ Admin creates client via UI → client appears in index
  • ✓ User clicks app on /apps → redirected with encrypted token
  • ✓ External app decrypts token → valid payload
  • ✓ Admin regenerates secret → old token fails, new works
  • ✓ Admin disables SSO → /apps shows "Direct Link"

Security tests:

  • ✓ Token expiry enforced (attempt with old timestamp → 401)
  • ✓ Tampered token rejected (modify encrypted bytes → decryption fails)
  • ✓ client_secret not exposed in HTTP response (check headers/cookies)
  • ✓ HTTPS enforced on redirect_url (cert validation)
  • ✓ Rate limiting on /sso-login (10 attempts/min/IP)

Performance tests:

  • ✓ Index page loads < 300ms with 100 clients
  • ✓ Token generation < 100ms
  • ✓ Decryption < 50ms

13. Troubleshooting & FAQs

Q: "SSO redirects but external app shows 'Invalid token'"

  • A:
    • Confirm client_secret matches on both sides
    • Check cipher: both must use AES-256-CBC (or agreed cipher)
    • Verify token not expired (check DTR server clock vs client clock)
    • Ensure encryption happens before URL encoding (no double-encoding)

Q: "Client not appearing on /apps"

  • A:
    • Confirm is_sso = true flag is set
    • Check Client::where('is_sso', true)->get() returns the record
    • Verify icon URL is accessible (check attachment storage path)

Q: "Regenerate Secret message says 'success' but external app still fails"

  • A:
    • Old tokens encrypted with old secret still fail (expected)
    • External app must update .env with new secret and restart
    • Wait 5–10 minutes after regenerate (for session caches to clear)

Q: "Token contains user email/roles — PII exposure?"

  • A:
    • Payload is encrypted; only client with secret can decrypt
    • Use HTTPS to prevent interception
    • Shorter token expiry (60 min) limits risk window
    • Consider signing token instead of symmetric encryption if higher security needed

Q: "How to revoke all active SSO sessions?"

  • A:
    • Disable client: set is_sso = false → future tokens not generated
    • Regenerate secret → old tokens fail decryption
    • No immediate session revocation on client-side (client app responsible)

Q: "Can I use same client_secret for multiple apps?"

  • A:
    • ❌ Not recommended (violates principle of least privilege)
    • Each app should have unique secret
    • If one compromised, only that app affected

Appendix: Code Examples & SQL

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