Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,26 @@ OIDC_CLIENT_SECRET=
# This is the URL where your application will receive the authentication response from the OIDC provider.
OIDC_REDIRECT_URI=https://<LIMAN_URL_HERE>/api/auth/oidc/callback

# OIDC SSL CERTIFICATE VERIFICATION
# Keep enabled in production. Set to false only when the OIDC provider uses a
# self-signed certificate that is not installed in Liman's trusted CA store.
OIDC_SSL_VERIFY=true

# TRUSTED APPLICATION AUTHENTICATION HANDOFF
# Optional. Lets confidential applications complete login through Liman OIDC
# without exposing the Liman JWT to the browser. Generate every client secret
# with at least 32 cryptographically random characters. Redirect URIs are
# exact-match and must use HTTPS outside explicit loopback development.
# Example:
# AUTH_HANDOFF_CLIENTS='{"netex":{"secret":"<AT_LEAST_32_RANDOM_CHARACTERS>","redirect_uris":["https://netex.example/auth/liman/callback"]}}'
AUTH_HANDOFF_CLIENTS={}

# One-time handoff authorization code lifetime in seconds (clamped to 30-300).
AUTH_HANDOFF_CODE_TTL=60

# Development only: allow http://localhost, http://127.0.0.1, and http://[::1].
AUTH_HANDOFF_ALLOW_INSECURE_LOOPBACK=false

# OIDC AUTHORIZATION ENDPOINT
# The endpoint used for user authentication.
# This is typically the authorization endpoint of your OIDC provider.
Expand Down Expand Up @@ -341,4 +361,4 @@ REDIS_SSL_VERIFY_PEER=true
# REDIS SSL VERIFY PEER NAME
# Verify the peer's certificate name
# Values: true, false
REDIS_SSL_VERIFY_PEER_NAME=false
REDIS_SSL_VERIFY_PEER_NAME=false
148 changes: 94 additions & 54 deletions app/Classes/Authentication/Authenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,99 @@ class Authenticator
*/
public static function createNewToken($token, ?Request $request = null)
{
$request ??= request();
[$user, $return, $tokenTimeout] = self::recordLogin($request);

// OIDC kullanıcıları için callback URL'den ana sayfaya redirect
if ($user->auth_type === 'oidc' && $request && $request->has('callback_url')) {
$callbackUrl = $request->input('callback_url');

// Callback URL'yi parse et ve ana sayfaya redirect URL'i oluştur
$parsedUrl = parse_url($callbackUrl);
$baseUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] .
(isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '');

$redirectResponse = redirect($baseUrl . '/');

// Cookie'leri redirect response'a ekle
return $redirectResponse
->withCookie(cookie(
'token',
$token,
$tokenTimeout,
null,
$request->getHost(),
true,
true,
false
))
->withCookie(cookie(
'currentUser',
json_encode($return),
$tokenTimeout,
null,
$request->getHost(),
true,
false,
false
));
}

return response()->json($return)->withCookie(cookie(
'token',
$token,
$tokenTimeout,
null,
$request->getHost(),
true,
true,
false
))->withCookie(cookie(
'currentUser',
json_encode($return),
$tokenTimeout,
null,
$request->getHost(),
true,
false,
false
));
}

/**
* Build the confidential response stored behind a one-time handoff code.
* The JWT is returned only by the authenticated server-to-server exchange.
*
* @param string $token
* @return array<string, mixed>
*/
public static function createHandoffToken($token, ?Request $request = null): array
{
$request ??= request();
[$user, $return] = self::recordLogin($request);

return [
'access_token' => $token,
'token_type' => 'Bearer',
'expired_at' => $return['expired_at'],
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'status' => $user->status,
'auth_type' => $user->auth_type,
],
];
}

/**
* Persist login audit data and return the shared token response metadata.
*
* @return array{0: User, 1: array<string, mixed>, 2: int}
*/
private static function recordLogin(?Request $request): array
{
$request ??= request();
$id = auth('api')->user()->id;
$user = User::find($id);

Expand Down Expand Up @@ -123,60 +216,7 @@ public static function createNewToken($token, ?Request $request = null)
$tokenTimeout = auth('api')->factory()->getTTL() * 60;
}

// OIDC kullanıcıları için callback URL'den ana sayfaya redirect
if ($user->auth_type === 'oidc' && $request && $request->has('callback_url')) {
$callbackUrl = $request->input('callback_url');

// Callback URL'yi parse et ve ana sayfaya redirect URL'i oluştur
$parsedUrl = parse_url($callbackUrl);
$baseUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] .
(isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '');

$redirectResponse = redirect($baseUrl . '/');

// Cookie'leri redirect response'a ekle
return $redirectResponse
->withCookie(cookie(
'token',
$token,
$tokenTimeout,
null,
$request->getHost(),
true,
true,
false
))
->withCookie(cookie(
'currentUser',
json_encode($return),
$tokenTimeout,
null,
$request->getHost(),
true,
false,
false
));
}

return response()->json($return)->withCookie(cookie(
'token',
$token,
$tokenTimeout,
null,
$request->getHost(),
true,
true,
false
))->withCookie(cookie(
'currentUser',
json_encode($return),
$tokenTimeout,
null,
$request->getHost(),
true,
false,
false
));
return [$user, $return, $tokenTimeout];
}

/**
Expand Down
183 changes: 183 additions & 0 deletions app/Classes/Authentication/Handoff/AuthenticationHandoffService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

namespace App\Classes\Authentication\Handoff;

use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;

/**
* Issues encrypted, short-lived authorization codes and atomically exchanges
* them for Liman JWT payloads. Codes are bound to a confidential client,
* exact redirect URI, and an RFC 7636 S256 challenge.
*/
class AuthenticationHandoffService
{
private const CODE_CACHE_PREFIX = 'auth_handoff:code:';

private const LOCK_CACHE_PREFIX = 'auth_handoff:lock:';

public function __construct(
private ?TrustedClientRegistry $clients = null,
) {
$this->clients ??= new TrustedClientRegistry;
}

/**
* @param array<string, mixed> $handoff
* @return array<string, string>|null
*/
public function authorizeInitiation(array $handoff): ?array
{
return $this->clients->authorizeInitiation($handoff);
}

/**
* @param array<string, string> $handoff
* @param array<string, mixed> $tokenPayload
*/
public function issue(array $handoff, array $tokenPayload): string
{
$code = $this->randomCode();
$digest = hash('sha256', $code);
$ttl = (int) config('auth_handoff.code_ttl', 60);
$expiresAt = time() + $ttl;

$payload = [
'version' => 1,
'client_id' => $handoff['client_id'],
'redirect_uri' => $handoff['redirect_uri'],
'code_challenge' => $handoff['code_challenge'],
'code_challenge_method' => 'S256',
'token' => $tokenPayload,
'expires_at' => $expiresAt,
];

Cache::put(
self::CODE_CACHE_PREFIX.$digest,
Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)),
$ttl,
);

Log::info('Authentication handoff issued', [
'client_id' => $handoff['client_id'],
'expires_at' => $expiresAt,
]);

return $code;
}

/**
* @return array<string, mixed>|null
*/
public function exchange(
string $clientId,
string $clientSecret,
string $code,
string $codeVerifier,
string $redirectUri,
): ?array {
if (! $this->clients->authenticate($clientId, $clientSecret)
|| ! $this->clients->redirectRegistered($clientId, $redirectUri)
|| ! preg_match('/\A[A-Za-z0-9_-]{43}\z/', $code)
|| ! preg_match('/\A[A-Za-z0-9\-._~]{43,128}\z/', $codeVerifier)) {
return null;
}

$digest = hash('sha256', $code);
$lock = Cache::lock(self::LOCK_CACHE_PREFIX.$digest, 5);
if (! $lock->get()) {
return null;
}

try {
$encrypted = Cache::get(self::CODE_CACHE_PREFIX.$digest);
if (! is_string($encrypted)) {
return null;
}

try {
$payload = json_decode(Crypt::decryptString($encrypted), true, 32, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
Cache::forget(self::CODE_CACHE_PREFIX.$digest);
Log::warning('Discarded invalid authentication handoff record', [
'client_id' => $clientId,
]);

return null;
}

$expectedChallenge = $this->codeChallenge($codeVerifier);
if (! is_array($payload)
|| ($payload['version'] ?? null) !== 1
|| ($payload['client_id'] ?? null) !== $clientId
|| ($payload['redirect_uri'] ?? null) !== $redirectUri
|| ($payload['code_challenge_method'] ?? null) !== 'S256'
|| ! is_string($payload['code_challenge'] ?? null)
|| ! hash_equals($payload['code_challenge'], $expectedChallenge)
|| ! is_int($payload['expires_at'] ?? null)
|| $payload['expires_at'] < time()
|| ! is_array($payload['token'] ?? null)) {
return null;
}

// Forget while holding the distributed lock: a successful code can
// be redeemed exactly once, even across multiple Liman replicas.
Cache::forget(self::CODE_CACHE_PREFIX.$digest);

Log::info('Authentication handoff redeemed', [
'client_id' => $clientId,
]);

return $payload['token'];
} finally {
$this->release($lock);
}
}

/**
* @param array<string, string> $handoff
*/
public function successRedirect(array $handoff, string $code): string
{
return $handoff['redirect_uri'].'?'.http_build_query([
'code' => $code,
'state' => $handoff['state'],
], '', '&', PHP_QUERY_RFC3986);
}

/**
* @param array<string, string> $handoff
*/
public function errorRedirect(array $handoff, string $error): string
{
if (! preg_match('/\A[a-z][a-z0-9_]{0,63}\z/', $error)) {
$error = 'server_error';
}

return $handoff['redirect_uri'].'?'.http_build_query([
'error' => $error,
'state' => $handoff['state'],
], '', '&', PHP_QUERY_RFC3986);
}

private function randomCode(): string
{
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}

private function codeChallenge(string $verifier): string
{
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
}

private function release(Lock $lock): void
{
try {
$lock->release();
} catch (\Throwable $e) {
Log::warning('Authentication handoff lock release failed');
}
}
}
Loading
Loading