authentikate/app/Http/Controllers/OIDCController.php
Javier Feliz 6a3971257a
Some checks failed
linter / quality (push) Successful in 6m36s
tests / ci (push) Has been cancelled
Update dockerfile
2025-08-02 19:28:34 -04:00

276 lines
9.1 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Livewire\ConsentScreen;
use App\Models\Application;
use App\Models\AuthenticationToken;
use App\Models\User;
use DateTimeImmutable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Illuminate\Http\Request;
class OIDCController extends Controller
{
public function authorize(Request $request)
{
$client = Application::where('client_id', $request->client_id)->firstOrFail();
if ($client->redirect_uri !== $request->redirect_uri) {
abort(403, 'Redirect URI mismatch');
}
$user = auth()->user();
// Check if user has auto-approval enabled and has previously authorized this app
$hasAuthorizedBefore = $user->tokens()->where('application_id', $client->id)->exists();
if ($user->auto_approve_apps && $hasAuthorizedBefore) {
// Auto-approve: generate code and redirect directly
$code = Str::random(40);
Log::info("Auto-approving and caching code: $code");
Cache::put("auth_code:$code", [
'user_id' => $user->id,
'client_id' => $client->id,
'scope' => $request->scope,
'code_challenge' => $request->code_challenge ?? null,
'code_challenge_method' => $request->code_challenge_method ?? null,
'nonce' => $request->input('nonce') ?? null,
], now()->addMinutes(5));
return redirect($request->redirect_uri . '?code=' . $code . '&state=' . $request->state);
}
// Standard flow: show confirmation screen
$code = Str::random(40);
Log::info("Caching code: $code");
Cache::put("auth_code:$code", [
'user_id' => $user->id,
'client_id' => $client->id,
'scope' => $request->scope,
'code_challenge' => $request->code_challenge ?? null,
'code_challenge_method' => $request->code_challenge_method ?? null,
'nonce' => $request->input('nonce') ?? null,
], now()->addMinutes(5));
session([
'app_id' => $client->id,
'redirect_on_confirm' => $request->redirect_uri . '?code=' . $code . '&state=' . $request->state
]);
return redirect(route('auth.confirm'));
}
public function token(Request $request)
{
Log::info("Got back code: {$request->code}");
$payload = Cache::pull("auth_code:{$request->code}");
if (!$payload) {
Log::error('Invalid or expired auth code');
abort(403, 'Invalid or expired auth code');
}
Log::info($payload);
// We only trust the client ID we saved from the /authorize request. Fuck
// whatever comes in the request
$client = Application::findOrFail($payload['client_id']);
if ($request->has('code_verifier')) {
// PKCE validation
$verifier = $request->code_verifier;
$method = $payload['code_challenge_method'] ?? 'plain';
$valid = match ($method) {
'S256' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=') === $payload['code_challenge'],
'plain' => $verifier === $payload['code_challenge'],
default => false,
};
if (!$valid) {
abort(403, 'Invalid PKCE code_verifier');
}
} elseif ($request->has('client_id') && $request->has('client_secret')) {
// Client credentials validation
if ($request->client_id !== $client->client_id) {
abort(403, 'Client ID mismatch');
}
if (!hash_equals($client->client_secret, $request->client_secret)) {
abort(403, 'Invalid client secret');
}
} else {
// No authentication provided
abort(403, 'Missing authentication');
}
// Validate redirect_uri
if ($request->redirect_uri !== $client->redirect_uri) {
abort(403, 'Redirect URI mismatch');
}
// if ($client->redirect_uri !== $request->redirect_uri) {
// Log::error('Redirect URI mismatch');
// abort(403, 'Redirect URI mismatch');
// }
$user = User::find($payload['user_id']);
// Generate ID token (JWT)
Log::info("GENERATING TOKEN");
$privateKey = InMemory::file($this->getPrivateKeyPath());
$token = (new JwtFacade())->issue(
new Sha256(),
$privateKey,
function (Builder $builder, DateTimeImmutable $issuedAt) use ($client, $user, $payload) {
$builder = $builder
->issuedBy(config('app.url'))
->permittedFor($client->client_id)
->relatedTo((string) $user->uuid)
->issuedAt($issuedAt)
->expiresAt($issuedAt->modify('+5 minutes'))
->withClaim('email', $user->email);
if (!empty($payload['nonce'])) {
$builder = $builder->withClaim('nonce', $payload['nonce']);
}
return $builder;
}
)->toString();
$accessToken = Str::random(64);
$user->tokens()->create([
'application_id' => $client->id,
'token' => $accessToken,
'issued_at' => now()->toDateTimeString(),
'expires_at' => now()->addMonth()->toDateTimeString(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return response()->json([
'access_token' => $accessToken,
'token_type' => 'Bearer',
'expires_in' => 3600,
'id_token' => $token,
]);
}
public function userinfo(Request $request)
{
$authHeader = $request->header('Authorization');
if (!$authHeader || !Str::startsWith($authHeader, 'Bearer ')) {
return response()->json(['error' => 'invalid_request'], 400);
}
$accessToken = Str::after($authHeader, 'Bearer ');
$token = AuthenticationToken::where('token', $accessToken)->first();
// TODO: Set token expirations
// if (!$token || $token->expires_at->isPast()) {
// return response()->json(['error' => 'invalid_token'], 401);
// }
if (empty($token)) {
return response()->json(['error' => 'invalid_token'], 401);
}
$user = $token->user;
if (empty($user)) {
return response()->json(['error' => 'invalid_token'], 401);
}
return response()->json([
'sub' => (string) $user->uuid,
'email' => $user->email,
'name' => $user->name,
'preferred_username' => $user->preferred_username,
'picture' => $user->avatar ? $user->avatarUrl() : null
]);
}
public function jwks()
{
$pubKeyPath = $this->getPublicKeyPath();
$keyDetails = openssl_pkey_get_details(openssl_pkey_get_public(file_get_contents($pubKeyPath)));
$modulus = $keyDetails['rsa']['n'];
$exponent = $keyDetails['rsa']['e'];
return response()->json([
'keys' => [[
'kty' => 'RSA',
'use' => 'sig',
'alg' => 'RS256',
'kid' => 'main-key', // optional, but good for key rotation
'n' => rtrim(strtr(base64_encode($modulus), '+/', '-_'), '='),
'e' => rtrim(strtr(base64_encode($exponent), '+/', '-_'), '='),
]]
]);
}
public function openidConfig()
{
return response()->json([
'issuer' => config('app.url'),
'authorization_endpoint' => route('auth.authorize'),
'token_endpoint' => route('auth.token'),
'userinfo_endpoint' => route('auth.userinfo'),
'scopes_supported' => ["openid", "profile", "email"],
'response_types_supported' => ["code"],
"jwks_uri" => route('auth.keys'),
"id_token_signing_alg_values_supported" => ["RS256"],
'claims_supported' => [
'sub',
'email',
'name',
'preferred_username',
'picture'
]
]);
}
public function logout(Request $request)
{
return view('logged-out');
}
/**
* Get the private key path based on environment.
*/
protected function getPrivateKeyPath(): string
{
if (app()->environment('testing')) {
return storage_path('testing/oauth/private.pem');
}
return storage_path('oauth/private.pem');
}
/**
* Get the public key path based on environment.
*/
protected function getPublicKeyPath(): string
{
if (app()->environment('testing')) {
return storage_path('testing/oauth/public.pem');
}
return storage_path('oauth/public.pem');
}
}