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()->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); // } $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' ] ]); } public function logout(Request $request) { return view('logged-out'); } }