generated from thegrind/laravel-dockerized
276 lines
9.1 KiB
PHP
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');
|
|
}
|
|
}
|