generated from thegrind/laravel-dockerized
221 lines
7.3 KiB
PHP
221 lines
7.3 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' => $user->preferred_username,
|
|
'picture' => $user->avatar ? $user->avatarUrl() : null
|
|
]);
|
|
}
|
|
|
|
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"],
|
|
'claims_supported' => [
|
|
'sub',
|
|
'email',
|
|
'name',
|
|
'preferred_username',
|
|
'picture'
|
|
]
|
|
]);
|
|
}
|
|
}
|