['account:read']]], tags: ['Account'], responses: [ new OA\Response(response: 200, description: 'Listado de clientes', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Client'))), new OA\Response(response: 422, description: 'El token no tiene un usuario asociado (ej. client_credentials)'), ] )] public function index(Request $request) { return response()->json( $this->requireUser($request)->oauthApps()->where('revoked', false)->orderByDesc('created_at')->get() ); } #[OA\Post( path: '/clients', summary: 'Registrar un nuevo cliente OAuth (tu app)', security: [['oauth2' => ['account:write']]], tags: ['Account'], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['name', 'grant_type'], properties: [ new OA\Property(property: 'name', type: 'string'), new OA\Property(property: 'grant_type', type: 'string', enum: ['authorization_code', 'client_credentials', 'password']), new OA\Property(property: 'redirect_uris', type: 'array', items: new OA\Items(type: 'string')), new OA\Property(property: 'confidential', type: 'boolean', default: true), ] ) ), responses: [ new OA\Response(response: 201, description: 'Cliente creado (incluye plain_secret una sola vez)'), new OA\Response(response: 422, description: 'Datos inválidos o token sin usuario asociado'), ] )] public function store(Request $request) { $user = $this->requireUser($request); $data = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'grant_type' => ['required', Rule::in(['authorization_code', 'client_credentials', 'password'])], 'redirect_uris' => ['required_if:grant_type,authorization_code', 'array'], 'redirect_uris.*' => ['url'], 'confidential' => ['boolean'], ]); $confidential = $request->boolean('confidential', true); $grantTypes = match ($data['grant_type']) { 'authorization_code' => ['authorization_code', 'refresh_token'], 'password' => ['password', 'refresh_token'], 'client_credentials' => ['client_credentials'], }; // client_credentials always needs a secret: there is no user present to prove identity. if ($data['grant_type'] === 'client_credentials') { $confidential = true; } $client = $user->oauthApps()->create([ 'name' => $data['name'], 'secret' => $confidential ? Str::random(40) : null, 'provider' => null, 'redirect_uris' => $data['redirect_uris'] ?? [], 'grant_types' => $grantTypes, 'revoked' => false, ]); AuditLog::record($request, 'client.created', 'client', $client->id, $client->name, [ 'grant_type' => $data['grant_type'], 'confidential' => $confidential, ]); return response()->json([ 'client' => $client, 'plain_secret' => $client->plainSecret, ], 201); } #[OA\Put( path: '/clients/{client}', summary: 'Editar uno de tus clientes OAuth', security: [['oauth2' => ['account:write']]], tags: ['Account'], parameters: [new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string'))], responses: [ new OA\Response(response: 200, description: 'Cliente actualizado', content: new OA\JsonContent(ref: '#/components/schemas/Client')), new OA\Response(response: 404, description: 'No es tuyo o no existe'), ] )] public function update(Request $request, Client $client) { abort_unless($client->owner_id === $this->requireUser($request)->id, 404); $data = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'redirect_uris' => ['array'], 'redirect_uris.*' => ['url'], ]); $client->update([ 'name' => $data['name'], 'redirect_uris' => $data['redirect_uris'] ?? $client->redirect_uris, ]); return response()->json($client); } #[OA\Post( path: '/clients/{client}/rotate-secret', summary: 'Rotar el secret de un client confidencial (el anterior sigue valiendo por 48hs)', description: 'El secret anterior no se corta al instante: sigue siendo válido durante un periodo de gracia (48hs), para no romper integraciones ya desplegadas que todavía no se actualizaron al secret nuevo.', security: [['oauth2' => ['account:write']]], tags: ['Account'], parameters: [new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string'))], responses: [ new OA\Response(response: 200, description: 'Secret rotado (incluye plain_secret una sola vez)'), new OA\Response(response: 404, description: 'No es tuyo o no existe'), new OA\Response(response: 422, description: 'El cliente no es confidencial (no tiene secret)'), ] )] public function rotateSecret(Request $request, Client $client) { abort_unless($client->owner_id === $this->requireUser($request)->id, 404); abort_unless($client->confidential(), 422, __('messages.client_not_confidential')); $plainSecret = ClientSecretRotation::rotate($client); AuditLog::record($request, 'client.secret_rotated', 'client', $client->id, $client->name, [ 'previous_secret_expires_at' => $client->previous_secret_expires_at, ]); return response()->json([ 'plain_secret' => $plainSecret, 'previous_secret_expires_at' => $client->previous_secret_expires_at, ]); } #[OA\Delete( path: '/clients/{client}', summary: 'Revocar uno de tus clientes OAuth (y sus tokens)', security: [['oauth2' => ['account:write']]], tags: ['Account'], parameters: [new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string'))], responses: [ new OA\Response(response: 204, description: 'Revocado'), new OA\Response(response: 404, description: 'No es tuyo o no existe'), ] )] public function destroy(Request $request, Client $client) { abort_unless($client->owner_id === $this->requireUser($request)->id, 404); $tokens = $client->tokens()->where('revoked', false)->get(); $client->update(['revoked' => true]); foreach ($tokens as $token) { TokenRevocation::revoke($token); } AuditLog::record($request, 'client.revoked', 'client', $client->id, $client->name, [ 'tokens_revoked' => $tokens->count(), ]); return response()->noContent(); } private function requireUser(Request $request): User { return $request->user('api') ?? abort(422, __('messages.self_service_requires_user')); } }