authentikate/app/Http/Controllers/OIDCController.php
Javier Feliz eeb6b4bc0e
Some checks failed
tests / ci (push) Waiting to run
linter / quality (push) Has been cancelled
Initial commit
2025-07-27 02:31:34 -04:00

213 lines
7.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)
{
// $valid = $request->validate([
// 'client_id' => 'required',
// 'redirect_uri' => 'required|url',
// 'response_type' => 'required|in:code',
// ]);
$client = Application::where('client_id', $request->client_id)->firstOrFail();
if ($client->redirect_uri !== $request->redirect_uri) {
abort(403, 'Redirect URI mismatch');
}
$code = Str::random(40);
Log::info("Caching code: $code");
Cache::put("auth_code:$code", [
'user_id' => auth()->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(storage_path('oauth/private.pem'));
$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->id)
->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()->updateOrCreate(['application_id' => $client->id], [
'token' => $accessToken
]);
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);
// }
$user = $token->user;
if (empty($token) || empty($user)) {
return response()->json(['error' => 'invalid_token'], 401);
}
return response()->json([
'sub' => (string) $user->id,
'email' => $user->email,
'name' => $user->name,
'preferred_username' => str($user->name)->slug()->toString(),
]);
}
public function jwks()
{
$pubKeyPath = storage_path('oauth/public.pem');
$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'),
'useringo_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"]
]);
}
}