From af56121d4a2376467d7936e5dcc83acad24c5a9f Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Tue, 22 Sep 2026 14:22:18 -0300 Subject: [PATCH 1/8] =?UTF-8?q?docs(identity):=20plano=20da=20API=20mobile?= =?UTF-8?q?=20=E2=80=94=20auth=20JWT=20dividido=20por=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs he4rt/heartdevs.com#531 --- docs/plans/2026-09-22-api-mobile-jwt.md | 168 ++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/plans/2026-09-22-api-mobile-jwt.md diff --git a/docs/plans/2026-09-22-api-mobile-jwt.md b/docs/plans/2026-09-22-api-mobile-jwt.md new file mode 100644 index 00000000..f7bb28d8 --- /dev/null +++ b/docs/plans/2026-09-22-api-mobile-jwt.md @@ -0,0 +1,168 @@ +--- +type: plan +title: 'API mobile do He4rt App — autenticação JWT e endpoints por feature' +module: identity, events, activity, profile +status: proposed +date: 2026-09-22 +author: tecrodrigocastro +related: + prd: he4rt/heartdevs.com#531 +--- + +# Plano — API mobile pro He4rt App (JWT) + +**Goal:** nascer a API que o [he4rt/he4rt-app](https://github.com/he4rt/he4rt-app) (NativePHP Mobile, repo separado) vai consumir por HTTP, cobrindo as três áreas do `panel-app` que o PRD pede: Timeline, Eventos (com check-in) e Perfil — autenticada por JWT, não Sanctum. + +**Por que JWT em vez de Sanctum:** o PoC anterior (documentado no PS da issue #531) já validou Sanctum funcionando (endpoint `/api/mobile/me` autenticado). A troca não é por limitação técnica do Sanctum — é para abrir a porta a **claims customizadas no próprio token** (role, tenant, capabilities) sem precisar de uma query extra por request, o que o Sanctum não oferece nativamente (seus tokens são opacos; abilities existem, mas não claims arbitrárias no payload). v1 usa claims mínimas (`sub`, `iat`, `exp`); o espaço pra crescer fica reservado via `JWTSubject::getJWTCustomClaims()`. + +**Pacote:** [`php-open-source-saver/jwt-auth`](https://github.com/PHP-Open-Source-Saver/jwt-auth) `^2.9` — fork ativo do `tymon/jwt-auth` original (parado), com suporte confirmado a Laravel `^12|^13` e PHP `^8.3`. Compatível com o stack atual (Laravel 13.25, PHP 8.4). + +**O que herdamos do PoC anterior e precisa ser desfeito:** a branch `poc/nativephp-mobile` (PR aberto em `tecrodrigocastro/heartdevs.com#1`) tem `HasApiTokens` no `User`, guard `sanctum` em `config/auth.php`, a migration de `personal_access_tokens` pra UUID, e `MobileMeController`/`api-mobile-routes.php` em `identity`. Esse trabalho vira **ponto de partida** da Feature 0 abaixo, mas o guard e o mecanismo de token trocam de Sanctum pra JWT — não dá pra só mergear como está. + +--- + +## Onde a API vive + +O PRD deixa isso como pergunta em aberto. Recomendação, com precedente já criado pelo próprio PoC: + +**Cada módulo de domínio dono do recurso expõe sua própria fatia da API mobile — sem módulo novo.** + +``` +app-modules/{modulo}/ +├── routes/api-mobile-routes.php <- prefixo /api/mobile/{recurso}, middleware auth:api (JWT) +└── src/Http/Controllers/Mobile/ <- controllers finos, só serializam o retorno de Actions existentes +``` + +Por quê: nenhuma das quatro features abaixo precisa compor domínio de MAIS de um módulo dentro do mesmo endpoint — Timeline só usa `activity`, Eventos só usa `events`, Perfil só usa `profile`, Auth só usa `identity`. `auth()->user()`/`auth()->id()` (guard JWT) resolve a identidade em qualquer um deles sem import cruzado. Isso respeita a regra do `CONTEXT-MAP.md` ("a API depende do domínio, nunca o contrário") sem inventar uma categoria de módulo nova (nem Domain, nem Integration, nem Presentation encaixam perfeitamente num módulo "api-mobile" dedicado). + +Se alguma feature futura precisar compor dois domínios num único payload (ex.: notificação cruzando Events + Activity), aí sim vale reabrir essa decisão. + +--- + +## Arquitetura de autenticação (Feature 0) + +### Guard novo + +`config/auth.php` ganha: + +```php +'guards' => [ + 'api' => ['driver' => 'jwt', 'provider' => 'users'], +], +``` + +`User` (`app-modules/identity/src/User/Models/User.php`) implementa `Tymon\JWTAuth\Contracts\JWTSubject` (nome do namespace do pacote pode variar — conferir na doc do `php-open-source-saver/jwt-auth` no momento de implementar): `getJWTIdentifier()` retorna `$this->getKey()` (UUID), `getJWTCustomClaims()` retorna `[]` na v1. + +### Fluxo OAuth → JWT + +O login web hoje (`OAuthController::getAuthenticate`) termina em `Auth::login()` (sessão) + redirect pro painel Filament. Isso não serve pro mobile — o app não tem sessão, e o controller depende de `filament()->setCurrentPanel()`. Em vez de reescrever esse fluxo, ele nasce **paralelo**, reaproveitando a resolução de usuário: + +1. App abre `Browser::auth()` (plugin do NativePHP) apontando pra `GET /api/mobile/auth/{provider}/redirect` (novo endpoint em `identity`, análogo ao `OAuthController::getRedirect` mas sem depender de painel Filament). +2. Provider (Discord/GitHub/Twitch — os três já suportados via `IdentityProvider::supportedProviders()`) redireciona pro callback padrão do OAuth. +3. Novo `MobileOAuthController::callback` reaproveita `HandleOAuthCallbackAction::execute()` (mesma Action do fluxo web) pra resolver/criar o `User` — mas em vez de `Auth::login()`, gera um **código de troca de uso único** (curto, ~60s de TTL, guardado em cache) e redireciona pro deep link do app: `NATIVEPHP_DEEPLINK_SCHEME://oauth/callback?code=...`. +4. App recebe o deep link, extrai o `code`, faz `POST /api/mobile/auth/exchange` com esse código. +5. Endpoint valida o código (uso único, expira, invalida-se após o uso), emite `{ access_token, refresh_token, expires_in }` via `php-open-source-saver/jwt-auth`. + +**Por que um código de troca em vez do JWT direto no deep link:** deep links (e o histórico de URLs do SO) não são um lugar seguro pra um token de longa duração passar. O código de troca é de uso único e vive segundos — se vazar, não serve pra nada depois do primeiro uso. + +### Refresh + +`POST /api/mobile/auth/refresh` — refresh token válido troca por um novo par de access/refresh. `POST /api/mobile/auth/logout` invalida o refresh token atual (blacklist do próprio pacote). + +### Endpoints da Feature 0 + +| Método | Rota | Descrição | +| ------ | -------------------------------------- | ------------------------------------------------------------------------------- | +| GET | `/api/mobile/auth/{provider}/redirect` | Inicia OAuth (Discord/GitHub/Twitch) | +| GET | `/api/mobile/auth/{provider}/callback` | Callback do provider → gera código de troca → deep link | +| POST | `/api/mobile/auth/exchange` | Código de troca → par de tokens JWT | +| POST | `/api/mobile/auth/refresh` | Refresh token → novo par | +| POST | `/api/mobile/auth/logout` | Invalida o refresh token atual | +| GET | `/api/mobile/me` | Usuário autenticado (id, username, avatar) — já existe no PoC, só troca o guard | + +--- + +## Feature 1 — Timeline + +Domínio: `He4rt\Activity\Timeline\*` (já existe, reaproveitado sem alteração). + +| Método | Rota | Action reaproveitada | +| ------ | -------------------------------------- | ---------------------------------------------------------------------------------------- | +| GET | `/api/mobile/timeline` | `TimelineFeed::builder()` (paginação simples, mesmo padrão do `Feed.php` do `panel-app`) | +| POST | `/api/mobile/timeline` | `CreatePost` + `CreatePostDTO` | +| POST | `/api/mobile/timeline/{post}/replies` | `CreateReply` + `CreateReplyDTO` | +| DELETE | `/api/mobile/timeline/replies/{reply}` | `DeleteReply` | + +Sem gap de domínio — é serialização pura em cima do que já existe. O corpo de resposta precisa de um API Resource novo (`TimelinePostResource`) já que a UI mobile não usa view Blade; padrão Eloquent API Resource, conforme `laravel/core` guideline deste repo. + +**Fora do v1, decisão explícita**: reações (`withCount('reactions')` aparece no `Feed.php`) — o PRD não menciona reagir como escopo v1 do app; incluir a contagem na resposta é grátis, mas o endpoint de reagir fica pra depois se a issue não abrir esse escopo. + +--- + +## Feature 2 — Eventos + +Domínio: `He4rt\Events\*`. + +| Método | Rota | Action reaproveitada | +| ------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/mobile/events` | `Event::query()->viewableByParticipant()` (scope já existe, usado no `EventDetail.php`) | +| GET | `/api/mobile/events/{event}` | idem + `enrollmentPolicy`, computa os mesmos booleans do `EventDetail.php` (`canApply`, `canConfirmPresence`, `isEventFull`) num Resource | +| POST | `/api/mobile/events/{event}/enroll` | `EnrollUserAction` + `EnrollUserDTO` (RSVP e Application, mesma Action cobre os dois) | +| GET | `/api/mobile/events/{event}/qr` | Serializa `$enrollment->qrToken->token` (dado cru — o app renderiza o QR nativamente, não precisa do SVG que o Livewire gera) | +| POST | `/api/mobile/events/{event}/check-in` | `NumericCodeCheckInAction` (ver gap abaixo pro método QR) | + +### Gap de domínio encontrado: não existe self-check-in por QR + +O PRD pede "check-in por QR code (via Scanner) com o código numérico como alternativa" — ou seja, os dois métodos alimentando a **mesma ação de self-check-in**, só mudando a forma de entrada (câmera vs teclado). Investigando o domínio (`app-modules/events/src/CheckIn/`): + +- `NumericCodeCheckInAction` — **self-service** (`TriggeredBy::User`, ator = o próprio dono da enrollment), valida contra `CheckInCode` (código compartilhado por evento/dia, com expiração e limite de usos). +- `QrCheckInAction` — **existe, mas é outra coisa**: é o staff escaneando o QR pessoal do participante (`TriggeredBy::Admin` fixo no código, valida contra `QrToken`, token 1:1 com a enrollment). É provavelmente o que alimenta um scanner no `panel-admin`, não o app do participante. + +Não existe hoje uma ação self-service equivalente à `NumericCodeCheckInAction` que aceite entrada por QR. Duas formas de fechar esse gap (decisão a bater com o time antes de codar a Feature 2): + +1. **QR só codifica o mesmo código compartilhado** (`CheckInCode.code`) — o venue projeta o código como texto E como QR na mesma tela; o Scanner do app só preenche o campo automaticamente. Nesse caso não precisa de Action nova: o endpoint aceita o valor decodificado como se fosse digitado, chama `NumericCodeCheckInAction` do mesmo jeito, e o `method` gravado no `CheckIn` continua sendo `NumericCode` (ou passa a receber o `method` como parâmetro pra registrar `QrCode` de verdade — mudança pequena na Action/DTO). +2. **QR é uma entidade própria** (novo token, não o `CheckInCode` compartilhado) — precisa de uma `QrCodeSelfCheckInAction` nova, espelhando a `NumericCodeCheckInAction` mas validando um token diferente. Mais trabalho, mais uma tabela ou reuso do `QrToken` com uma regra nova de quem pode consumir (hoje `QrToken` é pensado pra ser consumido por um `Admin`, não pelo próprio dono). + +Recomendação: opção 1 é bem mais barata e resolve o que o PRD pede ("QR como alternativa ao numérico", não "QR como terceiro sistema"). Fica registrado aqui pra não repetir a investigação — mas é uma decisão de produto, não só técnica, então deveria ser confirmada na issue antes da Feature 2 entrar em código. + +--- + +## Feature 3 — Perfil + +Domínio: `He4rt\Profile\*`. + +| Método | Rota | Action/Model reaproveitado | +| ------ | --------------------- | --------------------------------------------------------------- | +| GET | `/api/mobile/profile` | `Profile::ensureExists(auth()->id())` — mesmo padrão usado hoje | + +v1 é só leitura (o PRD explicitamente escopa "visualização do próprio perfil" — editar fica de fora). Um `ProfileResource` serializa os campos públicos do model (nickname, headline, seniority, social_links, etc.) — sem gap de domínio, `UpsertProfile`/`SyncProfileSkills` já existem se a edição entrar em escopo depois. + +--- + +## Fora de escopo (herdado do PRD, sem mudança) + +- Reaproveitar as classes Livewire do `panel-app` como UI — arquitetura do NativePHP Mobile não permite. +- Funcionalidade de domínio nova além do que já existe no `panel-app` (reações no feed, edição de perfil, etc.) — a não ser que a issue #531 seja atualizada pra abrir esse escopo. +- Notificações push — mesma pendência do PRD original, sem decisão ainda. +- Publicação nas lojas — fora do escopo desta API. + +--- + +## Riscos e perguntas em aberto + +1. **Gap do QR self-check-in (Feature 2)** — decisão de produto, não só técnica; ver seção acima. +2. **Deep link scheme** (`NATIVEPHP_DEEPLINK_SCHEME`) — precisa ser registrado no `he4rt-app` e o valor comunicado pra esta API configurar o redirect do passo 3 do fluxo OAuth. +3. **Rate limiting da API mobile** — os endpoints de escrita (postar, check-in) precisam de throttle próprio, análogo ao `RateLimiter` que `NumericCodeCheckIn.php` já usa no Livewire; replicar o mesmo limite na Action ou no middleware da rota. +4. **Blacklist do refresh token** — `php-open-source-saver/jwt-auth` precisa de um storage pra blacklist (cache/DB); confirmar qual driver de cache este projeto já usa em produção antes de habilitar `JWT_BLACKLIST_ENABLED`. +5. **Claims futuras** — o PRD não pede isso agora; só está sendo deixado como gancho arquitetural (`getJWTCustomClaims()`). Não implementar claims de role/tenant sem um caso de uso concreto puxando. + +--- + +## Ordem de implementação sugerida + +1. **Feature 0 (Auth)** — bloqueia todo o resto; sem token, nenhuma outra feature autentica. +2. **Feature 3 (Perfil)** — menor superfície, bom smoke test do guard `api` novo de ponta a ponta antes de features com escrita. +3. **Feature 1 (Timeline)** — leitura + escrita simples (post/reply), sem gap de domínio. +4. **Feature 2 (Eventos)** — a mais complexa (enrollment + dois métodos de check-in); decisão do gap de QR (seção acima) deveria estar fechada antes de começar. + +Cada feature vira sua própria branch/PR neste repo (`heartdevs.com`), seguindo a convenção `feature/` ou `story/531-` já documentada em `.ai/rules`. O client (`he4rt-app`) consome cada endpoint conforme ele fica pronto — não precisa esperar a API inteira pra começar a integrar a Feature 0/3. From daa6646ec8806f24c72a54ca2204d74496d2361d Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Tue, 22 Sep 2026 14:32:43 -0300 Subject: [PATCH 2/8] =?UTF-8?q?feat(identity):=20API=20mobile=20=E2=80=94?= =?UTF-8?q?=20login=20OAuth=20trocado=20por=20par=20de=20tokens=20JWT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard "api" (php-open-source-saver/jwt-auth): endpoints de redirect/callback OAuth (Discord/GitHub/Twitch) que devolvem um código de troca de uso único via deep link, GET /api/mobile/me, refresh e logout. Implementa a Feature 0 de docs/plans/2026-09-22-api-mobile-jwt.md. Refs he4rt/heartdevs.com#531 --- .env.example | 4 + .env.testing.example | 3 + .../identity/routes/api-mobile-routes.php | 34 ++ .../Auth/Actions/ExchangeMobileCodeAction.php | 26 + .../Actions/IssueMobileExchangeCodeAction.php | 28 + .../Auth/Actions/IssueMobileTokenAction.php | 27 + .../identity/src/Auth/DTOs/MobileTokenDTO.php | 26 + .../Auth/Exceptions/MobileAuthException.php | 15 + .../Mobile/MobileAuthController.php | 61 +++ .../Controllers/Mobile/MobileMeController.php | 25 + .../Mobile/MobileOAuthController.php | 100 ++++ app-modules/identity/src/User/Models/User.php | 16 +- .../Feature/Auth/MobileAuthControllerTest.php | 69 +++ .../Auth/MobileOAuthControllerTest.php | 120 ++++ composer.json | 1 + composer.lock | 512 ++++++++++++++---- config/auth.php | 5 + config/jwt.php | 326 +++++++++++ config/services.php | 4 + 19 files changed, 1297 insertions(+), 105 deletions(-) create mode 100644 app-modules/identity/routes/api-mobile-routes.php create mode 100644 app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php create mode 100644 app-modules/identity/src/Auth/Actions/IssueMobileExchangeCodeAction.php create mode 100644 app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php create mode 100644 app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php create mode 100644 app-modules/identity/src/Auth/Exceptions/MobileAuthException.php create mode 100644 app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileAuthController.php create mode 100644 app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php create mode 100644 app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php create mode 100644 app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php create mode 100644 app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php create mode 100644 config/jwt.php diff --git a/.env.example b/.env.example index a5ea69c8..00aa5a93 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,7 @@ GITHUB_API_TOKEN= # Dev.to — slug da organização cujos artigos o contents:sync-articles busca. # A chave de API de cada pessoa é conectada pelo painel (/app/profile → Conexões), não aqui. DEVTO_ORG_SLUG=he4rt + +# API mobile (he4rt-app) — autenticação JWT, gerar com `php artisan jwt:secret`. +JWT_SECRET= +HE4RT_APP_DEEPLINK_SCHEME=he4rtapp diff --git a/.env.testing.example b/.env.testing.example index 88c9040e..f58bf208 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -81,3 +81,6 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" DISCORD_TOKEN= + +# API mobile (he4rt-app) — valor fixo, não é segredo de produção. +JWT_SECRET=testing-jwt-secret-do-not-use-in-production diff --git a/app-modules/identity/routes/api-mobile-routes.php b/app-modules/identity/routes/api-mobile-routes.php new file mode 100644 index 00000000..1acc81c8 --- /dev/null +++ b/app-modules/identity/routes/api-mobile-routes.php @@ -0,0 +1,34 @@ +middleware('api') + ->group(static function (): void { + Route::prefix('auth')->group(static function (): void { + Route::get('/{provider}/redirect', [MobileOAuthController::class, 'redirect']) + ->name('mobile.oauth.redirect'); + + Route::get('/{provider}/callback', [MobileOAuthController::class, 'callback']) + ->name('mobile.oauth.callback'); + + Route::post('/exchange', [MobileAuthController::class, 'exchange']) + ->name('mobile.auth.exchange'); + + Route::post('/refresh', [MobileAuthController::class, 'refresh']) + ->name('mobile.auth.refresh'); + + Route::post('/logout', [MobileAuthController::class, 'logout']) + ->middleware('auth:api') + ->name('mobile.auth.logout'); + }); + + Route::get('/me', MobileMeController::class) + ->middleware('auth:api') + ->name('mobile.me'); + }); diff --git a/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php b/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php new file mode 100644 index 00000000..dcacef9c --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php @@ -0,0 +1,26 @@ +find($userId); + + throw_if($user === null, MobileAuthException::invalidExchangeCode()); + + return $user; + } +} diff --git a/app-modules/identity/src/Auth/Actions/IssueMobileExchangeCodeAction.php b/app-modules/identity/src/Auth/Actions/IssueMobileExchangeCodeAction.php new file mode 100644 index 00000000..62e839f1 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/IssueMobileExchangeCodeAction.php @@ -0,0 +1,28 @@ +id, self::TTL_SECONDS); + + return $code; + } +} diff --git a/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php b/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php new file mode 100644 index 00000000..531c8882 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php @@ -0,0 +1,27 @@ +login($user); + + return new MobileTokenDTO( + accessToken: $token, + tokenType: 'bearer', + expiresIn: config()->integer('jwt.ttl') * 60, + ); + } +} diff --git a/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php b/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php new file mode 100644 index 00000000..f70c472f --- /dev/null +++ b/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php @@ -0,0 +1,26 @@ + + */ + public function toArray(): array + { + return [ + 'access_token' => $this->accessToken, + 'token_type' => $this->tokenType, + 'expires_in' => $this->expiresIn, + ]; + } +} diff --git a/app-modules/identity/src/Auth/Exceptions/MobileAuthException.php b/app-modules/identity/src/Auth/Exceptions/MobileAuthException.php new file mode 100644 index 00000000..7a5113e0 --- /dev/null +++ b/app-modules/identity/src/Auth/Exceptions/MobileAuthException.php @@ -0,0 +1,15 @@ +validate([ + 'code' => ['required', 'string'], + ]); + + try { + $user = $exchangeCode->execute($request->string('code')->toString()); + } catch (MobileAuthException $mobileAuthException) { + return response()->json(['message' => $mobileAuthException->getMessage()], 401); + } + + return response()->json($issueToken->execute($user)->toArray()); + } + + public function refresh(): JsonResponse + { + /** @var JWTGuard $guard */ + $guard = Auth::guard('api'); + + try { + $token = $guard->refresh(); + } catch (JWTException $jwtException) { + return response()->json(['message' => $jwtException->getMessage()], 401); + } + + $refreshed = new MobileTokenDTO( + accessToken: $token, + tokenType: 'bearer', + expiresIn: config()->integer('jwt.ttl') * 60, + ); + + return response()->json($refreshed->toArray()); + } + + public function logout(): JsonResponse + { + Auth::guard('api')->logout(); + + return response()->json(status: 204); + } +} diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php new file mode 100644 index 00000000..a16d2196 --- /dev/null +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php @@ -0,0 +1,25 @@ +user(); + + return response()->json([ + 'id' => $user->id, + 'username' => $user->username, + 'avatar_url' => $user->getFilamentAvatarUrl(), + ]); + } +} diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php new file mode 100644 index 00000000..12069ec4 --- /dev/null +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php @@ -0,0 +1,100 @@ + */ + private const array SUPPORTED_PROVIDERS = [ + IdentityProvider::Discord, + IdentityProvider::GitHub, + IdentityProvider::Twitch, + ]; + + public function redirect(string $provider): RedirectResponse + { + $identityProvider = $this->resolveSupportedProvider($provider); + + try { + $client = $identityProvider->getClient(); + } catch (RuntimeException $runtimeException) { + Log::warning('Mobile OAuth client not configured', ['provider' => $provider, 'error' => $runtimeException->getMessage()]); + + return redirect()->to($this->deepLink('error', 'client_not_configured')); + } + + throw_unless($client instanceof OAuthClientContract, NotFoundHttpException::class); + + // returnUrl só precisa ser não-nulo: MobileOAuthController::callback() monta o + // próprio redirect de deep link e nunca lê $result->redirectUrl. + $state = new OAuthStateDTO( + intent: OAuthIntent::Login, + provider: $identityProvider, + panel: 'mobile', + returnUrl: 'mobile', + ); + + return redirect()->to($client->redirectUrl($state)); + } + + public function callback(string $provider, HandleOAuthCallbackAction $action, IssueMobileExchangeCodeAction $issueCode): RedirectResponse + { + $identityProvider = $this->resolveSupportedProvider($provider); + + $state = OAuthStateDTO::fromEncryptedString((string) request()->input('state')); + + $code = request()->input('code'); + $oauthDenied = $code === null || request()->has('error'); + + if ($oauthDenied) { + return redirect()->to($this->deepLink('error', 'access_denied')); + } + + try { + $result = $action->execute($state, $identityProvider, $code); + } catch (OAuthFlowException $oAuthFlowException) { + Log::warning('Mobile OAuth flow failed', ['provider' => $provider, 'error' => $oAuthFlowException->getMessage()]); + + return redirect()->to($this->deepLink('error', 'oauth_flow_failed')); + } + + $exchangeCode = $issueCode->execute($result->user); + + return redirect()->to($this->deepLink('callback', code: $exchangeCode)); + } + + private function resolveSupportedProvider(string $provider): IdentityProvider + { + $identityProvider = IdentityProvider::tryFrom($provider); + + throw_unless( + $identityProvider !== null && in_array($identityProvider, self::SUPPORTED_PROVIDERS, strict: true), + NotFoundHttpException::class, + ); + + return $identityProvider; + } + + private function deepLink(string $path, ?string $error = null, ?string $code = null): string + { + $scheme = config('services.he4rt_app.deeplink_scheme'); + $query = array_filter(['error' => $error, 'code' => $code]); + + return sprintf('%s://oauth/%s%s', $scheme, $path, $query === [] ? '' : '?'.http_build_query($query)); + } +} diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index da61c76e..02461468 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -30,6 +30,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Models\Role; @@ -54,7 +55,7 @@ #[UseFactory(factoryClass: UserFactory::class)] #[Table(name: 'users')] #[Hidden('password', 'remember_token', 'email_verified_at')] -final class User extends Authenticatable implements FilamentUser, HasMedia, HasName +final class User extends Authenticatable implements FilamentUser, HasMedia, HasName, JWTSubject { use HasAddress; /** @use HasFactory */ @@ -71,6 +72,19 @@ public function isSuperAdmin(): bool return $this->hasRole(UserRole::SuperAdmin); } + public function getJWTIdentifier(): string + { + return $this->getKey(); + } + + /** + * @return array + */ + public function getJWTCustomClaims(): array + { + return []; + } + /** * @return MorphMany */ diff --git a/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php b/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php new file mode 100644 index 00000000..7280d01c --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php @@ -0,0 +1,69 @@ +create(); + $code = resolve(IssueMobileExchangeCodeAction::class)->execute($user); + + $this->postJson('/api/mobile/auth/exchange', ['code' => $code]) + ->assertOk() + ->assertJsonStructure(['access_token', 'token_type', 'expires_in']) + ->assertJson(['token_type' => 'bearer']); +}); + +test('exchange consumes the code, so it cannot be reused', function (): void { + $user = User::factory()->create(); + $code = resolve(IssueMobileExchangeCodeAction::class)->execute($user); + + $this->postJson('/api/mobile/auth/exchange', ['code' => $code])->assertOk(); + $this->postJson('/api/mobile/auth/exchange', ['code' => $code])->assertUnauthorized(); +}); + +test('exchange rejects an unknown code', function (): void { + $this->postJson('/api/mobile/auth/exchange', ['code' => 'does-not-exist']) + ->assertUnauthorized(); +}); + +test('me returns the authenticated user', function (): void { + $user = User::factory()->create(['username' => 'he4rtdev']); + $token = Auth::guard('api')->login($user); + + $this->getJson('/api/mobile/me', ['Authorization' => "Bearer {$token}"]) + ->assertOk() + ->assertJson(['id' => $user->id, 'username' => 'he4rtdev']); +}); + +test('me rejects a request without a token', function (): void { + $this->getJson('/api/mobile/me')->assertUnauthorized(); +}); + +test('refresh issues a new token', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + + $response = $this->postJson('/api/mobile/auth/refresh', [], ['Authorization' => "Bearer {$token}"]) + ->assertOk() + ->assertJsonStructure(['access_token', 'token_type', 'expires_in']); + + expect($response->json('access_token'))->not->toBe($token); +}); + +test('refresh rejects a missing token', function (): void { + $this->postJson('/api/mobile/auth/refresh')->assertUnauthorized(); +}); + +test('logout invalidates the token', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + + $this->postJson('/api/mobile/auth/logout', [], ['Authorization' => "Bearer {$token}"]) + ->assertNoContent(); + + $this->getJson('/api/mobile/me', ['Authorization' => "Bearer {$token}"]) + ->assertUnauthorized(); +}); diff --git a/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php b/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php new file mode 100644 index 00000000..323b8c6c --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php @@ -0,0 +1,120 @@ +instance(GitHubOAuthClient::class, new readonly class($access, $user) implements OAuthClientContract + { + public function __construct( + private OAuthAccessDTO $access, + private OAuthUserDTO $user, + ) {} + + public function redirectUrl(?OAuthStateDTO $state = null): string + { + return 'https://github.test/oauth'; + } + + public function auth(string $code): OAuthAccessDTO + { + return $this->access; + } + + public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO + { + return $this->user; + } + }); +} + +test('redirect sends an unsupported provider to a 404', function (): void { + $this->get('/api/mobile/auth/devto/redirect')->assertNotFound(); +}); + +test('redirect forwards to the provider authorize URL', function (): void { + bindMobileGithubClient(); + + $this->get('/api/mobile/auth/github/redirect') + ->assertRedirect('https://github.test/oauth'); +}); + +test('callback with a denied authorization redirects to the app deep link with an error', function (): void { + $state = new OAuthStateDTO( + intent: OAuthIntent::Login, + provider: IdentityProvider::GitHub, + panel: 'mobile', + returnUrl: 'mobile', + ); + + $response = $this->get('/api/mobile/auth/github/callback?'.http_build_query([ + 'state' => (string) $state, + 'error' => 'access_denied', + ])); + + $response->assertRedirect(); + expect($response->headers->get('Location')) + ->toStartWith('he4rtapp://oauth/error') + ->toContain('error=access_denied'); +}); + +test('successful callback redirects to the app deep link with a one-time exchange code', function (): void { + bindMobileGithubClient(); + + $state = new OAuthStateDTO( + intent: OAuthIntent::Login, + provider: IdentityProvider::GitHub, + panel: 'mobile', + returnUrl: 'mobile', + ); + + $response = $this->get('/api/mobile/auth/github/callback?'.http_build_query([ + 'state' => (string) $state, + 'code' => 'auth-code', + ])); + + $response->assertRedirect(); + + $location = (string) $response->headers->get('Location'); + + expect($location)->toStartWith('he4rtapp://oauth/callback?code=') + ->and(User::query()->where('username', 'mobile-user')->exists())->toBeTrue(); +}); diff --git a/composer.json b/composer.json index 2deb4fd0..01dd84bf 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "monicahq/laravel-cloudflare": "^4.1", "owenvoke/blade-fontawesome": "^3.3.1", "phiki/phiki": "^2.2.1", + "php-open-source-saver/jwt-auth": "^2.9", "ryangjchandler/commonmark-blade-block": "^1.1.1", "saloonphp/saloon": "^4.0.1", "spatie/laravel-backup": "^10.3.2", diff --git a/composer.lock b/composer.lock index bf6cb9e9..5b06f595 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "25af23518b24f0cb163d843d734df60b", + "content-hash": "0316001caf3e07b15459586cc128ac57", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -277,23 +277,24 @@ }, { "name": "brick/math", - "version": "0.18.0", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "phpstan/phpstan": "2.1.22", + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", "phpunit/phpunit": "^11.5" }, "type": "library", @@ -324,7 +325,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.18.0" + "source": "https://github.com/brick/math/tree/1.0.0" }, "funding": [ { @@ -332,7 +333,7 @@ "type": "github" } ], - "time": "2026-06-14T18:21:03+00:00" + "time": "2026-09-12T10:28:18+00:00" }, { "name": "calebporzio/sushi", @@ -1867,27 +1868,25 @@ }, { "name": "doctrine/lexer", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c", + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5", - "psalm/plugin-phpunit": "^0.18.3", - "vimeo/psalm": "^5.21" + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" }, "type": "library", "autoload": { @@ -1924,7 +1923,7 @@ ], "support": { "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.0.1" + "source": "https://github.com/doctrine/lexer/tree/3.0.2" }, "funding": [ { @@ -1940,7 +1939,7 @@ "type": "tidelift" } ], - "time": "2024-02-05T11:56:58+00:00" + "time": "2026-06-14T20:44:06+00:00" }, { "name": "dragonmantank/cron-expression", @@ -4484,20 +4483,20 @@ }, { "name": "laravel/framework", - "version": "v13.31.0", + "version": "v13.33.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "7c75fbf93f91fa077d3df1c820cc14f4e59a9774" + "reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/7c75fbf93f91fa077d3df1c820cc14f4e59a9774", - "reference": "7c75fbf93f91fa077d3df1c820cc14f4e59a9774", + "url": "https://api.github.com/repos/laravel/framework/zipball/91188a17ceaa3dbace6e8a5f7abd0d042e466359", + "reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359", "shasum": "" }, "require": { - "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19", + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -4612,15 +4611,17 @@ "orchestra/testbench-core": "^11.0.0", "pda/pheanstalk": "^7.0.0 || ^8.0.0", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.2.14", "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", "predis/predis": "^2.3 || ^3.0", "rector/rector": "2.6.3", "resend/resend-php": "^1.0", "symfony/cache": "^7.4.0 || ^8.0.0", "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/mercure": "^0.8.0", "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", - "symfony/translation": "^7.4.0 || ^8.0.0" + "symfony/translation": "^7.4.0 || ^8.0.0", + "web-token/jwt-library": "^4.1" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", @@ -4656,8 +4657,10 @@ "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).", "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).", + "web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)." }, "type": "library", "extra": { @@ -4707,7 +4710,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-09-08T14:22:55+00:00" + "time": "2026-09-22T14:12:33+00:00" }, { "name": "laravel/head", @@ -5054,16 +5057,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.16", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", - "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", "shasum": "" }, "require": { @@ -5111,7 +5114,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-08-18T20:28:54+00:00" + "time": "2026-09-22T14:32:34+00:00" }, { "name": "laravel/telescope", @@ -5250,18 +5253,91 @@ }, "time": "2026-03-17T14:54:13+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/commonmark", - "version": "2.10.1", + "version": "2.10.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e" + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e", - "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f", + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f", "shasum": "" }, "require": { @@ -5355,7 +5431,7 @@ "type": "tidelift" } ], - "time": "2026-09-07T13:44:26+00:00" + "time": "2026-09-21T13:07:34+00:00" }, { "name": "league/config", @@ -6771,18 +6847,85 @@ ], "time": "2026-08-11T10:17:44+00:00" }, + { + "name": "namshi/jose", + "version": "7.2.3", + "source": { + "type": "git", + "url": "https://github.com/namshi/jose.git", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/namshi/jose/zipball/89a24d7eb3040e285dd5925fcad992378b82bcff", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff", + "shasum": "" + }, + "require": { + "ext-date": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-pcre": "*", + "ext-spl": "*", + "php": ">=5.5", + "symfony/polyfill-php56": "^1.0" + }, + "require-dev": { + "phpseclib/phpseclib": "^2.0", + "phpunit/phpunit": "^4.5|^5.0", + "satooshi/php-coveralls": "^1.0" + }, + "suggest": { + "ext-openssl": "Allows to use OpenSSL as crypto engine.", + "phpseclib/phpseclib": "Allows to use Phpseclib as crypto engine, use version ^2.0." + }, + "type": "library", + "autoload": { + "psr-4": { + "Namshi\\JOSE\\": "src/Namshi/JOSE/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Nadalin", + "email": "alessandro.nadalin@gmail.com" + }, + { + "name": "Alessandro Cinelli (cirpo)", + "email": "alessandro.cinelli@gmail.com" + } + ], + "description": "JSON Object Signing and Encryption library for PHP.", + "keywords": [ + "JSON Web Signature", + "JSON Web Token", + "JWS", + "json", + "jwt", + "token" + ], + "support": { + "issues": "https://github.com/namshi/jose/issues", + "source": "https://github.com/namshi/jose/tree/master" + }, + "time": "2016-12-05T07:27:31+00:00" + }, { "name": "nesbot/carbon", - "version": "3.13.2", + "version": "3.14.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + "reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", - "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2", + "reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2", "shasum": "" }, "require": { @@ -6874,7 +7017,7 @@ "type": "tidelift" } ], - "time": "2026-08-08T11:40:35+00:00" + "time": "2026-09-12T20:45:19+00:00" }, { "name": "nette/php-generator", @@ -7598,6 +7741,99 @@ ], "time": "2026-07-22T19:51:40+00:00" }, + { + "name": "php-open-source-saver/jwt-auth", + "version": "v2.9.3", + "source": { + "type": "git", + "url": "https://github.com/PHP-Open-Source-Saver/jwt-auth.git", + "reference": "1bdb72de5e60fcb02264d7aba4bd4608939e3798" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-Open-Source-Saver/jwt-auth/zipball/1bdb72de5e60fcb02264d7aba4bd4608939e3798", + "reference": "1bdb72de5e60fcb02264d7aba4bd4608939e3798", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/auth": "^12|^13", + "illuminate/contracts": "^12|^13", + "illuminate/http": "^12|^13", + "illuminate/support": "^12|^13", + "lcobucci/jwt": "^5.4", + "namshi/jose": "^7.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "illuminate/console": "^12|^13", + "illuminate/routing": "^12|^13", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^10|^11", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5|^11" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "JWTAuth": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth", + "JWTFactory": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory" + }, + "providers": [ + "PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "PHPOpenSourceSaver\\JWTAuth\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sean Tymon", + "email": "tymon148@gmail.com", + "homepage": "https://tymon.xyz", + "role": "Forked package creator | Developer" + }, + { + "name": "Eric Schricker", + "email": "eric.schricker@adiutabyte.de", + "role": "Developer" + }, + { + "name": "Fabio William Conceição", + "email": "messhias@gmail.com", + "role": "Developer" + }, + { + "name": "Max Snow", + "email": "contact@maxsnow.me", + "role": "Developer" + } + ], + "description": "JSON Web Token Authentication for Laravel and Lumen", + "homepage": "https://github.com/PHP-Open-Source-Saver/jwt-auth", + "keywords": [ + "Authentication", + "JSON Web Token", + "auth", + "jwt", + "laravel" + ], + "support": { + "issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues", + "source": "https://github.com/PHP-Open-Source-Saver/jwt-auth" + }, + "time": "2026-08-21T05:00:05+00:00" + }, { "name": "phpoption/phpoption", "version": "1.10.0", @@ -8575,20 +8811,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -8647,9 +8883,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "ratchet/pawl", @@ -11137,16 +11373,16 @@ }, { "name": "symfony/console", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac" + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/eb7d9957d66739649e931ce7a9d05dab69f8abac", - "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac", + "url": "https://api.github.com/repos/symfony/console/zipball/29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", "shasum": "" }, "require": { @@ -11213,7 +11449,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.6" + "source": "https://github.com/symfony/console/tree/v8.1.7" }, "funding": [ { @@ -11233,7 +11469,7 @@ "type": "tidelift" } ], - "time": "2026-08-25T14:18:42+00:00" + "time": "2026-09-13T10:55:57+00:00" }, { "name": "symfony/css-selector", @@ -11765,16 +12001,16 @@ }, { "name": "symfony/finder", - "version": "v8.1.5", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8d7acede2b2ae07605783d1c43e49b5767036474" + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8d7acede2b2ae07605783d1c43e49b5767036474", - "reference": "8d7acede2b2ae07605783d1c43e49b5767036474", + "url": "https://api.github.com/repos/symfony/finder/zipball/4fbe46a3eb64abf8a57f0364075b91f4a233e062", + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062", "shasum": "" }, "require": { @@ -11809,7 +12045,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.5" + "source": "https://github.com/symfony/finder/tree/v8.1.7" }, "funding": [ { @@ -11829,7 +12065,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-09-10T19:14:39+00:00" }, { "name": "symfony/html-sanitizer", @@ -11905,16 +12141,16 @@ }, { "name": "symfony/http-foundation", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb" + "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/093b78326f649c3a9db922b9f17123b6aeb3b8fb", - "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", + "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", "shasum": "" }, "require": { @@ -11962,7 +12198,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.6" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.7" }, "funding": [ { @@ -11982,20 +12218,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:55+00:00" + "time": "2026-09-14T17:47:24+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355" + "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", - "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", + "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", "shasum": "" }, "require": { @@ -12072,7 +12308,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.6" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.7" }, "funding": [ { @@ -12092,20 +12328,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T21:40:49+00:00" + "time": "2026-09-15T07:12:52+00:00" }, { "name": "symfony/mailer", - "version": "v8.1.5", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "89f43137da74b8f1aab37c99926482b7084f51b9" + "reference": "8783380ecdafa23d36fc90c5873fd44b635c6e10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/89f43137da74b8f1aab37c99926482b7084f51b9", - "reference": "89f43137da74b8f1aab37c99926482b7084f51b9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/8783380ecdafa23d36fc90c5873fd44b635c6e10", + "reference": "8783380ecdafa23d36fc90c5873fd44b635c6e10", "shasum": "" }, "require": { @@ -12152,7 +12388,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.1.5" + "source": "https://github.com/symfony/mailer/tree/v8.1.7" }, "funding": [ { @@ -12172,20 +12408,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-09-15T06:01:24+00:00" }, { "name": "symfony/mime", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f" + "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", - "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", + "url": "https://api.github.com/repos/symfony/mime/zipball/773ac57f20e2795bdadb65b5b1b5f4850d498283", + "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283", "shasum": "" }, "require": { @@ -12238,7 +12474,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.6" + "source": "https://github.com/symfony/mime/tree/v8.1.7" }, "funding": [ { @@ -12258,7 +12494,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:06:25+00:00" + "time": "2026-09-04T11:02:17+00:00" }, { "name": "symfony/options-resolver", @@ -12753,6 +12989,74 @@ ], "time": "2026-05-27T06:59:30+00:00" }, + { + "name": "symfony/polyfill-php56", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php56.git", + "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "metapackage", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + }, + "branch-alias": { + "dev-main": "1.20-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 5.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php56/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, { "name": "symfony/polyfill-php73", "version": "v1.37.0", @@ -13322,16 +13626,16 @@ }, { "name": "symfony/process", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d863f5e70d7c87abb906ac11b61f83036093000b" + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d863f5e70d7c87abb906ac11b61f83036093000b", - "reference": "d863f5e70d7c87abb906ac11b61f83036093000b", + "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3", + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3", "shasum": "" }, "require": { @@ -13363,7 +13667,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.1.6" + "source": "https://github.com/symfony/process/tree/v8.1.7" }, "funding": [ { @@ -13383,7 +13687,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-09-02T12:40:29+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -13642,16 +13946,16 @@ }, { "name": "symfony/string", - "version": "v8.1.2", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", "shasum": "" }, "require": { @@ -13708,7 +14012,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.2" + "source": "https://github.com/symfony/string/tree/v8.1.7" }, "funding": [ { @@ -13728,7 +14032,7 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:35:25+00:00" + "time": "2026-09-11T14:51:16+00:00" }, { "name": "symfony/translation", @@ -13985,16 +14289,16 @@ }, { "name": "symfony/var-dumper", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "3783365b58972f4779254d98372af80fbf15e170" + "reference": "741a8c4c948b0bb9bd3653daacd3644c73c40ea5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3783365b58972f4779254d98372af80fbf15e170", - "reference": "3783365b58972f4779254d98372af80fbf15e170", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/741a8c4c948b0bb9bd3653daacd3644c73c40ea5", + "reference": "741a8c4c948b0bb9bd3653daacd3644c73c40ea5", "shasum": "" }, "require": { @@ -14048,7 +14352,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.6" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.7" }, "funding": [ { @@ -14068,7 +14372,7 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:55+00:00" + "time": "2026-09-03T16:02:27+00:00" }, { "name": "symfony/yaml", diff --git a/config/auth.php b/config/auth.php index 18a46a52..9c49b178 100644 --- a/config/auth.php +++ b/config/auth.php @@ -44,6 +44,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'jwt', + 'provider' => 'users', + ], ], /* diff --git a/config/jwt.php b/config/jwt.php new file mode 100644 index 00000000..49ad9636 --- /dev/null +++ b/config/jwt.php @@ -0,0 +1,326 @@ + env('JWT_SECRET'), + + /* + |-------------------------------------------------------------------------- + | JWT Authentication Keys + |-------------------------------------------------------------------------- + | + | The algorithm you are using, will determine whether your tokens are + | signed with a random string (defined in `JWT_SECRET`) or using the + | following public & private keys. + | + | Symmetric Algorithms: + | HS256, HS384 & HS512 will use `JWT_SECRET`. + | + | Asymmetric Algorithms: + | RS256, RS384 & RS512 / ES256, ES384 & ES512 will use the keys below. + | + */ + + 'keys' => [ + /* + |-------------------------------------------------------------------------- + | Public Key + |-------------------------------------------------------------------------- + | + | A path or resource to your public key. + | + | E.g. 'file://path/to/public/key' + | + */ + + 'public' => env('JWT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Private Key + |-------------------------------------------------------------------------- + | + | A path or resource to your private key. + | + | E.g. 'file://path/to/private/key' + | + */ + + 'private' => env('JWT_PRIVATE_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passphrase + |-------------------------------------------------------------------------- + | + | The passphrase for your private key. Can be null if none set. + | + */ + + 'passphrase' => env('JWT_PASSPHRASE'), + ], + + /* + |-------------------------------------------------------------------------- + | JWT time to live + |-------------------------------------------------------------------------- + | + | Specify the length of time (in minutes) that the token will be valid for. + | Defaults to 1 hour. + | + | You can also set this to null, to yield a never expiring token. + | Some people may want this behaviour for e.g. a mobile app. + | This is not particularly recommended, so make sure you have appropriate + | systems in place to revoke the token if necessary. + | Notice: If you set this to null you should remove 'exp' element from 'required_claims' list. + | + */ + + 'ttl' => (int) env('JWT_TTL', 60), + + /* + |-------------------------------------------------------------------------- + | Refresh time to live + |-------------------------------------------------------------------------- + | + | Specify the length of time (in minutes) that the token can be refreshed within. + | This defines the refresh window, during which the user can refresh their token + | before re-authentication is required. + | + | By default, a refresh will NOT issue a new "iat" (issued at) timestamp. If changed + | to true, each refresh will issue a new "iat" timestamp, extending the refresh + | period from the most recent refresh. This results in a rolling refresh + | + | To retain a fluid refresh window from the last refresh action (i.e., the behavior between + | version 2.5.0 and 2.8.2), set "refresh_iat" to true. With this setting, the refresh + | window will renew with each subsequent refresh. + | + | The refresh ttl defaults to 2 weeks. + | + | You can also set this to null, to yield an infinite refresh time. + | Some may want this instead of never expiring tokens for e.g. a mobile app. + | This is not particularly recommended, so make sure you have appropriate + | systems in place to revoke the token if necessary. + | + */ + + 'refresh_iat' => env('JWT_REFRESH_IAT', default: false), + 'refresh_ttl' => (int) env('JWT_REFRESH_TTL', 20_160), + + /* + |-------------------------------------------------------------------------- + | JWT hashing algorithm + |-------------------------------------------------------------------------- + | + | Specify the hashing algorithm that will be used to sign the token. + | + | See here: https://github.com/namshi/jose/tree/master/src/Namshi/JOSE/Signer/OpenSSL + | for possible values. + | + */ + + 'algo' => env('JWT_ALGO', 'HS256'), + + /* + |-------------------------------------------------------------------------- + | Required Claims + |-------------------------------------------------------------------------- + | + | Specify the required claims that must exist in any token. + | A TokenInvalidException will be thrown if any of these claims are not + | present in the payload. + | + */ + + 'required_claims' => [ + 'iss', + 'iat', + 'exp', + 'nbf', + 'sub', + 'jti', + ], + + /* + |-------------------------------------------------------------------------- + | Persistent Claims + |-------------------------------------------------------------------------- + | + | Specify the claim keys to be persisted when refreshing a token. + | `sub` and `iat` will automatically be persisted, in + | addition to the these claims. + | + | Note: If a claim does not exist then it will be ignored. + | + */ + + 'persistent_claims' => [ + // 'foo', + // 'bar', + ], + + /* + |-------------------------------------------------------------------------- + | Lock Subject + |-------------------------------------------------------------------------- + | + | This will determine whether a `prv` claim is automatically added to + | the token. The purpose of this is to ensure that if you have multiple + | authentication models e.g. `App\User` & `App\OtherPerson`, then we + | should prevent one authentication request from impersonating another, + | if 2 tokens happen to have the same id across the 2 different models. + | + | Under specific circumstances, you may want to disable this behaviour + | e.g. if you only have one authentication model, then you would save + | a little on token size. + | + */ + + 'lock_subject' => true, + + /* + |-------------------------------------------------------------------------- + | Leeway + |-------------------------------------------------------------------------- + | + | This property gives the jwt timestamp claims some "leeway". + | Meaning that if you have any unavoidable slight clock skew on + | any of your servers then this will afford you some level of cushioning. + | + | This applies to the claims `iat`, `nbf` and `exp`. + | + | Specify in seconds - only if you know you need it. + | + */ + + 'leeway' => (int) env('JWT_LEEWAY', 0), + + /* + |-------------------------------------------------------------------------- + | Blacklist Enabled + |-------------------------------------------------------------------------- + | + | In order to invalidate tokens, you must have the blacklist enabled. + | If you do not want or need this functionality, then set this to false. + | + */ + + 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', default: true), + + /* + | ------------------------------------------------------------------------- + | Blacklist Grace Period + | ------------------------------------------------------------------------- + | + | When multiple concurrent requests are made with the same JWT, + | it is possible that some of them fail, due to token regeneration + | on every request. + | + | Set grace period in seconds to prevent parallel request failure. + | + */ + + 'blacklist_grace_period' => (int) env('JWT_BLACKLIST_GRACE_PERIOD', 0), + + /* + |-------------------------------------------------------------------------- + | Show blacklisted token option + |-------------------------------------------------------------------------- + | + | Specify if you want to show black listed token exception on the laravel logs. + | + */ + + 'show_black_list_exception' => env('JWT_SHOW_BLACKLIST_EXCEPTION', default: true), + + /* + |-------------------------------------------------------------------------- + | Cookies encryption + |-------------------------------------------------------------------------- + | + | By default Laravel encrypt cookies for security reason. + | If you decide to not decrypt cookies, you will have to configure Laravel + | to not encrypt your cookie token by adding its name into the $except + | array available in the middleware "EncryptCookies" provided by Laravel. + | see https://laravel.com/docs/master/responses#cookies-and-encryption + | for details. + | + | Set it to true if you want to decrypt cookies. + | + */ + + 'decrypt_cookies' => false, + + /* + |-------------------------------------------------------------------------- + | Cookie key name + |-------------------------------------------------------------------------- + | + | Specify the cookie key name that you would like to use for the cookie token. + | + */ + + 'cookie_key_name' => 'token', + + /* + |-------------------------------------------------------------------------- + | Providers + |-------------------------------------------------------------------------- + | + | Specify the various providers used throughout the package. + | + */ + + 'providers' => [ + /* + |-------------------------------------------------------------------------- + | JWT Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to create and decode the tokens. + | + */ + + 'jwt' => Lcobucci::class, + + /* + |-------------------------------------------------------------------------- + | Authentication Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to authenticate users. + | + */ + + 'auth' => Illuminate::class, + + /* + |-------------------------------------------------------------------------- + | Storage Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to store tokens in the blacklist. + | + */ + + 'storage' => PHPOpenSourceSaver\JWTAuth\Providers\Storage\Illuminate::class, + ], +]; diff --git a/config/services.php b/config/services.php index b56da8e1..dc425d3c 100644 --- a/config/services.php +++ b/config/services.php @@ -72,4 +72,8 @@ 'openai' => [ 'api_key' => env('OPENAI_API_KEY'), ], + + 'he4rt_app' => [ + 'deeplink_scheme' => env('HE4RT_APP_DEEPLINK_SCHEME', 'he4rtapp'), + ], ]; From 7858d00b2b685d299651951fb1a170de35c5e07c Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Tue, 22 Sep 2026 14:45:11 -0300 Subject: [PATCH 3/8] fix(identity): callback OAuth mobile e 401 de API testados end-to-end contra a app real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achados testando os endpoints de verdade (não só Pest): - Discord/GitHub/Twitch só conhecem UMA redirect_uri fixa por app, o callback web (/auth/oauth/{provider}). MobileOAuthController::callback nunca seria chamado de verdade. O callback web agora distingue o login mobile via OAuthIntent::MobileLogin (lido do state) e finaliza com código de troca + deep link em vez de duplicar rota de callback. - Qualquer request pra rota auth:api sem "Accept: application/json" dava 500 em vez de 401: o middleware padrão tenta redirect(route('login')), que este app não tem (login é só OAuth). Fix em bootstrap/app.php. Suíte inteira (1749 testes) verde depois da mudança global. --- .../identity/routes/api-mobile-routes.php | 7 +-- .../Actions/HandleOAuthCallbackAction.php | 2 +- .../identity/src/Auth/Enums/OAuthIntent.php | 1 + .../Mobile/MobileOAuthController.php | 48 +++---------------- .../Auth/Http/Controllers/OAuthController.php | 16 +++++++ .../src/Auth/Support/MobileOAuthDeepLink.php | 16 +++++++ .../Auth/MobileOAuthControllerTest.php | 19 +++++--- bootstrap/app.php | 16 ++++++- 8 files changed, 72 insertions(+), 53 deletions(-) create mode 100644 app-modules/identity/src/Auth/Support/MobileOAuthDeepLink.php diff --git a/app-modules/identity/routes/api-mobile-routes.php b/app-modules/identity/routes/api-mobile-routes.php index 1acc81c8..62f393ce 100644 --- a/app-modules/identity/routes/api-mobile-routes.php +++ b/app-modules/identity/routes/api-mobile-routes.php @@ -11,12 +11,13 @@ ->middleware('api') ->group(static function (): void { Route::prefix('auth')->group(static function (): void { + // O callback do OAuth é único por provider e já está cadastrado + // apontando pra rota web (auth/oauth/{provider} → OAuthController:: + // getAuthenticate), que também finaliza o login mobile quando + // intent=MobileLogin. Ver He4rt\Identity\Auth\Support\MobileOAuthDeepLink. Route::get('/{provider}/redirect', [MobileOAuthController::class, 'redirect']) ->name('mobile.oauth.redirect'); - Route::get('/{provider}/callback', [MobileOAuthController::class, 'callback']) - ->name('mobile.oauth.callback'); - Route::post('/exchange', [MobileAuthController::class, 'exchange']) ->name('mobile.auth.exchange'); diff --git a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php index a073d499..a60d3e12 100644 --- a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php +++ b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php @@ -34,7 +34,7 @@ public function execute(OAuthStateDTO $state, IdentityProvider $provider, string $oauthUser = $client->getAuthenticatedUser($access); $user = match ($state->intent) { - OAuthIntent::Login => $this->findOrCreateUser->execute($oauthUser), + OAuthIntent::Login, OAuthIntent::MobileLogin => $this->findOrCreateUser->execute($oauthUser), OAuthIntent::Link => $this->resolveAuthenticatedUser(), }; diff --git a/app-modules/identity/src/Auth/Enums/OAuthIntent.php b/app-modules/identity/src/Auth/Enums/OAuthIntent.php index 3431d31c..90161d2d 100644 --- a/app-modules/identity/src/Auth/Enums/OAuthIntent.php +++ b/app-modules/identity/src/Auth/Enums/OAuthIntent.php @@ -8,4 +8,5 @@ enum OAuthIntent: string { case Login = 'login'; case Link = 'link'; + case MobileLogin = 'mobile_login'; } diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php index 12069ec4..b3b3192b 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php @@ -6,11 +6,9 @@ use App\Contracts\OAuthClientContract; use App\Http\Controllers\Controller; -use He4rt\Identity\Auth\Actions\HandleOAuthCallbackAction; -use He4rt\Identity\Auth\Actions\IssueMobileExchangeCodeAction; use He4rt\Identity\Auth\DTOs\OAuthStateDTO; use He4rt\Identity\Auth\Enums\OAuthIntent; -use He4rt\Identity\Auth\Exceptions\OAuthFlowException; +use He4rt\Identity\Auth\Support\MobileOAuthDeepLink; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Log; @@ -35,15 +33,17 @@ public function redirect(string $provider): RedirectResponse } catch (RuntimeException $runtimeException) { Log::warning('Mobile OAuth client not configured', ['provider' => $provider, 'error' => $runtimeException->getMessage()]); - return redirect()->to($this->deepLink('error', 'client_not_configured')); + return redirect()->to(MobileOAuthDeepLink::build('error', 'client_not_configured')); } throw_unless($client instanceof OAuthClientContract, NotFoundHttpException::class); - // returnUrl só precisa ser não-nulo: MobileOAuthController::callback() monta o - // próprio redirect de deep link e nunca lê $result->redirectUrl. + // Discord/GitHub/Twitch têm UMA redirect_uri fixa cadastrada (o callback + // web em /auth/oauth/{provider} — ver *OAuthClient::callbackUrl()). Por + // isso o retorno do provider sempre bate em OAuthController::getAuthenticate, + // nunca aqui; é ele quem finaliza o login mobile lendo intent=MobileLogin. $state = new OAuthStateDTO( - intent: OAuthIntent::Login, + intent: OAuthIntent::MobileLogin, provider: $identityProvider, panel: 'mobile', returnUrl: 'mobile', @@ -52,32 +52,6 @@ public function redirect(string $provider): RedirectResponse return redirect()->to($client->redirectUrl($state)); } - public function callback(string $provider, HandleOAuthCallbackAction $action, IssueMobileExchangeCodeAction $issueCode): RedirectResponse - { - $identityProvider = $this->resolveSupportedProvider($provider); - - $state = OAuthStateDTO::fromEncryptedString((string) request()->input('state')); - - $code = request()->input('code'); - $oauthDenied = $code === null || request()->has('error'); - - if ($oauthDenied) { - return redirect()->to($this->deepLink('error', 'access_denied')); - } - - try { - $result = $action->execute($state, $identityProvider, $code); - } catch (OAuthFlowException $oAuthFlowException) { - Log::warning('Mobile OAuth flow failed', ['provider' => $provider, 'error' => $oAuthFlowException->getMessage()]); - - return redirect()->to($this->deepLink('error', 'oauth_flow_failed')); - } - - $exchangeCode = $issueCode->execute($result->user); - - return redirect()->to($this->deepLink('callback', code: $exchangeCode)); - } - private function resolveSupportedProvider(string $provider): IdentityProvider { $identityProvider = IdentityProvider::tryFrom($provider); @@ -89,12 +63,4 @@ private function resolveSupportedProvider(string $provider): IdentityProvider return $identityProvider; } - - private function deepLink(string $path, ?string $error = null, ?string $code = null): string - { - $scheme = config('services.he4rt_app.deeplink_scheme'); - $query = array_filter(['error' => $error, 'code' => $code]); - - return sprintf('%s://oauth/%s%s', $scheme, $path, $query === [] ? '' : '?'.http_build_query($query)); - } } diff --git a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php index a55cd9f8..02b8bbf3 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php @@ -7,9 +7,11 @@ use App\Contracts\OAuthClientContract; use App\Http\Controllers\Controller; use He4rt\Identity\Auth\Actions\HandleOAuthCallbackAction; +use He4rt\Identity\Auth\Actions\IssueMobileExchangeCodeAction; use He4rt\Identity\Auth\DTOs\OAuthStateDTO; use He4rt\Identity\Auth\Enums\OAuthIntent; use He4rt\Identity\Auth\Exceptions\OAuthFlowException; +use He4rt\Identity\Auth\Support\MobileOAuthDeepLink; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; @@ -57,6 +59,10 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act $oauthDenied = $code === null || request()->has('error'); if ($oauthDenied) { + if ($state->intent === OAuthIntent::MobileLogin) { + return redirect()->to(MobileOAuthDeepLink::build('error', 'access_denied')); + } + $fallbackUrl = $state->returnUrl ?? '/'; return redirect()->to($fallbackUrl); @@ -67,9 +73,19 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act } catch (OAuthFlowException $oAuthFlowException) { Log::warning('OAuth flow failed', ['provider' => $provider, 'error' => $oAuthFlowException->getMessage()]); + if ($state->intent === OAuthIntent::MobileLogin) { + return redirect()->to(MobileOAuthDeepLink::build('error', 'oauth_flow_failed')); + } + return redirect()->to($state->returnUrl ?? '/'); } + if ($result->intent === OAuthIntent::MobileLogin) { + $exchangeCode = resolve(IssueMobileExchangeCodeAction::class)->execute($result->user); + + return redirect()->to(MobileOAuthDeepLink::build('callback', code: $exchangeCode)); + } + if ($result->hasMergeConflict()) { session()->put('oauth_merge_pending', $result->mergeConflict->toSession()); diff --git a/app-modules/identity/src/Auth/Support/MobileOAuthDeepLink.php b/app-modules/identity/src/Auth/Support/MobileOAuthDeepLink.php new file mode 100644 index 00000000..b0990cf3 --- /dev/null +++ b/app-modules/identity/src/Auth/Support/MobileOAuthDeepLink.php @@ -0,0 +1,16 @@ + $error, 'code' => $code]); + + return sprintf('%s://oauth/%s%s', $scheme, $path, $query === [] ? '' : '?'.http_build_query($query)); + } +} diff --git a/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php b/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php index 323b8c6c..1eadc41d 100644 --- a/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php +++ b/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php @@ -10,6 +10,7 @@ use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\User\Models\User; use He4rt\IntegrationGithub\OAuth\GitHubOAuthClient; +use Illuminate\Support\Facades\Auth; function bindMobileGithubClient(): void { @@ -77,15 +78,18 @@ public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO ->assertRedirect('https://github.test/oauth'); }); -test('callback with a denied authorization redirects to the app deep link with an error', function (): void { +test('a denied mobile authorization on the shared web callback redirects to the app deep link with an error', function (): void { + // Discord/GitHub/Twitch só conhecem UMA redirect_uri por app: a rota web + // /auth/oauth/{provider} (OAuthController::getAuthenticate). O login mobile + // é distinguido pelo intent codificado no state, não por uma rota própria. $state = new OAuthStateDTO( - intent: OAuthIntent::Login, + intent: OAuthIntent::MobileLogin, provider: IdentityProvider::GitHub, panel: 'mobile', returnUrl: 'mobile', ); - $response = $this->get('/api/mobile/auth/github/callback?'.http_build_query([ + $response = $this->get('/auth/oauth/github?'.http_build_query([ 'state' => (string) $state, 'error' => 'access_denied', ])); @@ -96,17 +100,17 @@ public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO ->toContain('error=access_denied'); }); -test('successful callback redirects to the app deep link with a one-time exchange code', function (): void { +test('a successful mobile login on the shared web callback redirects to the app deep link with an exchange code, without starting a web session', function (): void { bindMobileGithubClient(); $state = new OAuthStateDTO( - intent: OAuthIntent::Login, + intent: OAuthIntent::MobileLogin, provider: IdentityProvider::GitHub, panel: 'mobile', returnUrl: 'mobile', ); - $response = $this->get('/api/mobile/auth/github/callback?'.http_build_query([ + $response = $this->get('/auth/oauth/github?'.http_build_query([ 'state' => (string) $state, 'code' => 'auth-code', ])); @@ -116,5 +120,6 @@ public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO $location = (string) $response->headers->get('Location'); expect($location)->toStartWith('he4rtapp://oauth/callback?code=') - ->and(User::query()->where('username', 'mobile-user')->exists())->toBeTrue(); + ->and(User::query()->where('username', 'mobile-user')->exists())->toBeTrue() + ->and(Auth::check())->toBeFalse(); }); diff --git a/bootstrap/app.php b/bootstrap/app.php index 86c27f27..afd8a9ef 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,6 +24,20 @@ $middleware->web(append: [ SetApplicationLocale::class, ]); + + // O app não tem rota "login" — auth é só via OAuth (Filament cuida do + // próprio redirect nos painéis). Sem isso, o middleware `auth`/`auth:api` + // tenta redirect(route('login')) pra qualquer client sem "Accept: + // application/json" e quebra com 500 (RouteNotFoundException). + $middleware->redirectGuestsTo(redirect: null); + }) + ->withExceptions(static function (Exceptions $exceptions): void { + // Sem isso, um cliente de API que não manda "Accept: application/json" + // (curl puro, a maioria dos clientes HTTP mobile) recebe um 500 em vez + // de 401/erro em JSON: o handler padrão tenta redirect(route('login')), + // que não existe neste app — login é só via OAuth. + $exceptions->shouldRenderJsonWhen( + fn ($request, $throwable): bool => $request->is('api/*') || $request->expectsJson(), + ); }) - ->withExceptions(static function (Exceptions $exceptions): void {}) ->create(); From a27f6aa13a22959d3967641dff13eef7e2a8d37f Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Tue, 22 Sep 2026 14:50:55 -0300 Subject: [PATCH 4/8] docs(identity): gera doc da API mobile via scramble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marca rotas auth:* como bearer na doc (scramble.security_strategy — não vinha configurado) e adiciona resumo/descrição nos endpoints mobile pro Stoplight Elements em /docs/3.x/api. Tipa o retorno de MobileTokenDTO::toArray() como array-shape pra schema de resposta mais preciso. Verificado contra o /docs/3.x/swagger.json gerado: os 5 endpoints aparecem, com bearer marcado corretamente em /me, /logout e no default do documento (redirect e exchange ficam públicos, como deveria). --- .../Auth/Actions/IssueMobileTokenAction.php | 1 + .../identity/src/Auth/DTOs/MobileTokenDTO.php | 2 +- .../Mobile/MobileAuthController.php | 21 +++++++++++++++++++ .../Controllers/Mobile/MobileMeController.php | 5 +++++ .../Mobile/MobileOAuthController.php | 8 +++++++ config/scramble.php | 8 +++++++ 6 files changed, 44 insertions(+), 1 deletion(-) diff --git a/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php b/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php index 531c8882..d71635d5 100644 --- a/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php +++ b/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php @@ -16,6 +16,7 @@ public function execute(User $user): MobileTokenDTO /** @var JWTGuard $guard */ $guard = Auth::guard('api'); + /** @var string $token */ $token = $guard->login($user); return new MobileTokenDTO( diff --git a/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php b/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php index f70c472f..ee58d3d3 100644 --- a/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php +++ b/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php @@ -13,7 +13,7 @@ public function __construct( ) {} /** - * @return array + * @return array{access_token: string, token_type: string, expires_in: int} */ public function toArray(): array { diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileAuthController.php index 198513a9..73ae423b 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileAuthController.php @@ -17,6 +17,13 @@ final class MobileAuthController extends Controller { + /** + * Trocar código de login por token + * + * Troca o código de uso único devolvido no deep link do OAuth (ver + * MobileOAuthController::redirect) por um par de token de acesso JWT. + * O código expira em 60s e só pode ser usado uma vez. + */ public function exchange(Request $request, ExchangeMobileCodeAction $exchangeCode, IssueMobileTokenAction $issueToken): JsonResponse { $request->validate([ @@ -32,12 +39,20 @@ public function exchange(Request $request, ExchangeMobileCodeAction $exchangeCod return response()->json($issueToken->execute($user)->toArray()); } + /** + * Renovar token de acesso + * + * Emite um novo token a partir do token atual do header Authorization, + * mesmo que já tenha expirado — desde que dentro da janela de refresh + * (jwt.refresh_ttl) e não esteja na blacklist. + */ public function refresh(): JsonResponse { /** @var JWTGuard $guard */ $guard = Auth::guard('api'); try { + /** @var string $token */ $token = $guard->refresh(); } catch (JWTException $jwtException) { return response()->json(['message' => $jwtException->getMessage()], 401); @@ -52,6 +67,12 @@ public function refresh(): JsonResponse return response()->json($refreshed->toArray()); } + /** + * Encerrar sessão + * + * Invalida o token de acesso atual (blacklist) — o mesmo token não + * autentica nem renova depois disso. + */ public function logout(): JsonResponse { Auth::guard('api')->logout(); diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php index a16d2196..a02119ca 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php @@ -11,6 +11,11 @@ final class MobileMeController extends Controller { + /** + * Usuário autenticado + * + * Retorna os dados básicos do usuário dono do token JWT atual. + */ public function __invoke(Request $request): JsonResponse { /** @var User $user */ diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php index b3b3192b..6f243d8a 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php @@ -24,6 +24,14 @@ final class MobileOAuthController extends Controller IdentityProvider::Twitch, ]; + /** + * Iniciar login OAuth + * + * Redireciona pro provider (discord, github ou twitch). O provider + * devolve o usuário pro callback web fixo, que redireciona de volta + * pro app via deep link com um código de troca de uso único — ver + * POST /api/mobile/auth/exchange. + */ public function redirect(string $provider): RedirectResponse { $identityProvider = $this->resolveSupportedProvider($provider); diff --git a/config/scramble.php b/config/scramble.php index 750b8ecc..738de92c 100644 --- a/config/scramble.php +++ b/config/scramble.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess; +use Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy; return [ /* @@ -134,5 +135,12 @@ RestrictedDocsAccess::class, ], + /* + * Marca como "bearer" as rotas protegidas por qualquer guard "auth:*" + * (ex.: auth:api, o guard JWT da API mobile) — sem isso, os endpoints + * protegidos aparecem na doc como se não precisassem de autenticação. + */ + 'security_strategy' => MiddlewareAuthSecurityStrategy::class, + 'extensions' => [], ]; From eeff6d3282dc5fe0b5eb5a0c8da78a232641935f Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Tue, 22 Sep 2026 14:56:31 -0300 Subject: [PATCH 5/8] =?UTF-8?q?chore(docs):=20atualiza=20vers=C3=A3o=20do?= =?UTF-8?q?=20portal=20de=203.x=20pra=204.x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O módulo he4rt/docs nasceu (#123) quando a branch principal do repo ainda era 3.x — a string ficou hardcoded em 5 lugares e nunca acompanhou o bump pra 4.x. Move resources/docs/3.x pro caminho novo, atualiza Documentation.php, DocsServiceProvider.php, config/docs.php e config/scramble.php, e o teste que trava a URL do Scramble. /docs/4.x/api agora serve a doc da API mobile gerada nesta branch. --- app-modules/docs/config/docs.php | 2 +- app-modules/docs/routes/docs-routes.php | 2 +- app-modules/docs/src/DocsServiceProvider.php | 6 +++--- app-modules/docs/src/Documentation.php | 2 +- .../docs/tests/Feature/Discovery/DocsRoutingTest.php | 2 +- config/scramble.php | 2 +- resources/docs/{3.x => 4.x}/convencoes-de-codigo.md | 0 resources/docs/{3.x => 4.x}/documentation.md | 0 resources/docs/{3.x => 4.x}/installation.md | 2 +- resources/docs/{3.x => 4.x}/primeiro-pull-request.md | 0 resources/docs/{3.x => 4.x}/releases.md | 2 +- resources/docs/{3.x => 4.x}/rodando-o-projeto.md | 0 12 files changed, 10 insertions(+), 10 deletions(-) rename resources/docs/{3.x => 4.x}/convencoes-de-codigo.md (100%) rename resources/docs/{3.x => 4.x}/documentation.md (100%) rename resources/docs/{3.x => 4.x}/installation.md (98%) rename resources/docs/{3.x => 4.x}/primeiro-pull-request.md (100%) rename resources/docs/{3.x => 4.x}/releases.md (94%) rename resources/docs/{3.x => 4.x}/rodando-o-projeto.md (100%) diff --git a/app-modules/docs/config/docs.php b/app-modules/docs/config/docs.php index a794db7b..a513924b 100644 --- a/app-modules/docs/config/docs.php +++ b/app-modules/docs/config/docs.php @@ -3,7 +3,7 @@ declare(strict_types=1); return [ - 'default_version' => '3.x', + 'default_version' => '4.x', 'cache' => [ 'enabled' => env('DOCS_CACHE_ENABLED', default: true), diff --git a/app-modules/docs/routes/docs-routes.php b/app-modules/docs/routes/docs-routes.php index 1a6fc7b7..f5fa2f63 100644 --- a/app-modules/docs/routes/docs-routes.php +++ b/app-modules/docs/routes/docs-routes.php @@ -14,7 +14,7 @@ Route::get('docs', [DocsController::class, 'index'])->name('docs.index'); // The section is constrained to known document types so Scramble's -// `docs/3.x/api` (and any other prefix) falls through to its own route. +// `docs/4.x/api` (and any other prefix) falls through to its own route. Route::get('docs/{section}/{path?}', [DocsController::class, 'show']) ->where('section', $sections) ->where('path', '.*') diff --git a/app-modules/docs/src/DocsServiceProvider.php b/app-modules/docs/src/DocsServiceProvider.php index bc1a3c93..e4b15bbd 100644 --- a/app-modules/docs/src/DocsServiceProvider.php +++ b/app-modules/docs/src/DocsServiceProvider.php @@ -58,9 +58,9 @@ public function boot(): void { $this->commands([CacheDocsCommand::class]); - Scramble::registerApi('3.x'); + Scramble::registerApi('4.x'); - Scramble::registerUiRoute(path: 'docs/3.x/api', api: '3.x'); - Scramble::registerJsonSpecificationRoute(path: 'docs/3.x/swagger.json', api: '3.x'); + Scramble::registerUiRoute(path: 'docs/4.x/api', api: '4.x'); + Scramble::registerJsonSpecificationRoute(path: 'docs/4.x/swagger.json', api: '4.x'); } } diff --git a/app-modules/docs/src/Documentation.php b/app-modules/docs/src/Documentation.php index 37cfc6db..02d435c3 100644 --- a/app-modules/docs/src/Documentation.php +++ b/app-modules/docs/src/Documentation.php @@ -40,7 +40,7 @@ public static function replaceLinks($version, RenderedContentInterface|string $c public static function getDocVersions(): array { return [ - '3.x' => '3.x', + '4.x' => '4.x', ]; } diff --git a/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php b/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php index 902afd04..99836932 100644 --- a/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php +++ b/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php @@ -46,7 +46,7 @@ it('does not let the catch-all hijack the Scramble api route', function (): void { $route = resolve(Router::class)->getRoutes()->match( - Request::create('/docs/3.x/api', 'GET'), + Request::create('/docs/4.x/api', 'GET'), ); expect($route->getActionName())->not->toContain(DocsController::class); diff --git a/config/scramble.php b/config/scramble.php index 738de92c..019f9664 100644 --- a/config/scramble.php +++ b/config/scramble.php @@ -27,7 +27,7 @@ /* * API version. */ - 'version' => '3.x', + 'version' => '4.x', /* * Description rendered on the home page of the API documentation (`/docs/api`). diff --git a/resources/docs/3.x/convencoes-de-codigo.md b/resources/docs/4.x/convencoes-de-codigo.md similarity index 100% rename from resources/docs/3.x/convencoes-de-codigo.md rename to resources/docs/4.x/convencoes-de-codigo.md diff --git a/resources/docs/3.x/documentation.md b/resources/docs/4.x/documentation.md similarity index 100% rename from resources/docs/3.x/documentation.md rename to resources/docs/4.x/documentation.md diff --git a/resources/docs/3.x/installation.md b/resources/docs/4.x/installation.md similarity index 98% rename from resources/docs/3.x/installation.md rename to resources/docs/4.x/installation.md index b07f2342..3ee69b2e 100644 --- a/resources/docs/3.x/installation.md +++ b/resources/docs/4.x/installation.md @@ -14,7 +14,7 @@ order: 1 ## Versioning Scheme -A documentação do portal é versionada por linha de release (por exemplo, `3.x`). +A documentação do portal é versionada por linha de release (por exemplo, `4.x`). Os arquivos vivem em `resources/docs/{version}/` e os links internos usam o placeholder `{{version}}` para apontar sempre para a versão corrente — assim a mesma página funciona em qualquer linha de release. diff --git a/resources/docs/3.x/primeiro-pull-request.md b/resources/docs/4.x/primeiro-pull-request.md similarity index 100% rename from resources/docs/3.x/primeiro-pull-request.md rename to resources/docs/4.x/primeiro-pull-request.md diff --git a/resources/docs/3.x/releases.md b/resources/docs/4.x/releases.md similarity index 94% rename from resources/docs/3.x/releases.md rename to resources/docs/4.x/releases.md index eece8c21..45028eb7 100644 --- a/resources/docs/3.x/releases.md +++ b/resources/docs/4.x/releases.md @@ -19,7 +19,7 @@ linha de release (`MAJOR.MINOR.PATCH`): - **MINOR** — funcionalidades novas retrocompatíveis. - **PATCH** — correções de bug retrocompatíveis. -A documentação acompanha a linha **MAJOR.x** (por exemplo, `3.x`). Os links entre +A documentação acompanha a linha **MAJOR.x** (por exemplo, `4.x`). Os links entre páginas usam o placeholder `{{version}}` para resolver sempre a versão corrente. diff --git a/resources/docs/3.x/rodando-o-projeto.md b/resources/docs/4.x/rodando-o-projeto.md similarity index 100% rename from resources/docs/3.x/rodando-o-projeto.md rename to resources/docs/4.x/rodando-o-projeto.md From a23455274da7c05d7b2e059a3d489c09c8715323 Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Tue, 22 Sep 2026 15:29:48 -0300 Subject: [PATCH 6/8] =?UTF-8?q?fix(identity):=20troca=20de=20c=C3=B3digo?= =?UTF-8?q?=20de=20exchange=20at=C3=B4mica=20(achado=20do=20CodeRabbit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache::pull() é get()+forget() como duas chamadas separadas — duas requisições concorrentes com o mesmo código podiam ambas ler o valor antes de qualquer uma apagar, mintando dois tokens da mesma autorização (CWE-367). ExchangeMobileCodeAction agora serializa leitura+remoção com Cache::lock(). Atualiza o plano com o contrato real (refresh usa o mesmo JWT, sem refresh_token separado; não existe rota de callback mobile própria) e documenta como débito técnico conhecido o outro achado da revisão: o deep link por custom scheme pode ser sequestrado por outro app no aparelho — a correção certa é PKCE, mas depende do he4rt-app também mudar, então não é fechada nesta PR. --- .../Auth/Actions/ExchangeMobileCodeAction.php | 30 +++++++++++++++---- .../Feature/Auth/MobileAuthControllerTest.php | 15 ++++++++++ docs/plans/2026-09-22-api-mobile-jwt.md | 28 +++++++++++------ 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php b/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php index dcacef9c..bed7c3ef 100644 --- a/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php +++ b/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php @@ -12,15 +12,33 @@ { public function execute(string $code): User { - /** @var string|null $userId */ - $userId = Cache::pull(IssueMobileExchangeCodeAction::cacheKey($code)); + $cacheKey = IssueMobileExchangeCodeAction::cacheKey($code); - throw_if($userId === null, MobileAuthException::invalidExchangeCode()); + // Cache::pull() é get()+forget() como duas chamadas separadas — sob + // concorrência, dois requests podem ler o mesmo código antes de + // qualquer um apagar e mintar dois tokens da mesma autorização. O + // lock serializa get+forget num bloco atômico por código. + $lock = Cache::lock('identity:mobile-oauth-exchange-lock:'.$code, 10); - $user = User::query()->find($userId); + if (!$lock->get()) { + throw MobileAuthException::invalidExchangeCode(); + } - throw_if($user === null, MobileAuthException::invalidExchangeCode()); + try { + /** @var string|null $userId */ + $userId = Cache::get($cacheKey); - return $user; + throw_if($userId === null, MobileAuthException::invalidExchangeCode()); + + Cache::forget($cacheKey); + + $user = User::query()->find($userId); + + throw_if($user === null, MobileAuthException::invalidExchangeCode()); + + return $user; + } finally { + $lock->release(); + } } } diff --git a/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php b/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php index 7280d01c..9e280e14 100644 --- a/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php +++ b/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php @@ -5,6 +5,7 @@ use He4rt\Identity\Auth\Actions\IssueMobileExchangeCodeAction; use He4rt\Identity\User\Models\User; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; test('exchange trades a valid code for a token pair', function (): void { $user = User::factory()->create(); @@ -24,6 +25,20 @@ $this->postJson('/api/mobile/auth/exchange', ['code' => $code])->assertUnauthorized(); }); +test('exchange rejects a concurrent redemption of the same code', function (): void { + $user = User::factory()->create(); + $code = resolve(IssueMobileExchangeCodeAction::class)->execute($user); + + // Simula um segundo request concorrente já segurando o lock antes do + // primeiro conseguir ler+apagar o código — prova que a troca é atômica. + $lock = Cache::lock('identity:mobile-oauth-exchange-lock:'.$code, 10); + $lock->get(); + + $this->postJson('/api/mobile/auth/exchange', ['code' => $code])->assertUnauthorized(); + + $lock->release(); +}); + test('exchange rejects an unknown code', function (): void { $this->postJson('/api/mobile/auth/exchange', ['code' => 'does-not-exist']) ->assertUnauthorized(); diff --git a/docs/plans/2026-09-22-api-mobile-jwt.md b/docs/plans/2026-09-22-api-mobile-jwt.md index f7bb28d8..75641e48 100644 --- a/docs/plans/2026-09-22-api-mobile-jwt.md +++ b/docs/plans/2026-09-22-api-mobile-jwt.md @@ -58,28 +58,29 @@ Se alguma feature futura precisar compor dois domínios num único payload (ex.: O login web hoje (`OAuthController::getAuthenticate`) termina em `Auth::login()` (sessão) + redirect pro painel Filament. Isso não serve pro mobile — o app não tem sessão, e o controller depende de `filament()->setCurrentPanel()`. Em vez de reescrever esse fluxo, ele nasce **paralelo**, reaproveitando a resolução de usuário: 1. App abre `Browser::auth()` (plugin do NativePHP) apontando pra `GET /api/mobile/auth/{provider}/redirect` (novo endpoint em `identity`, análogo ao `OAuthController::getRedirect` mas sem depender de painel Filament). -2. Provider (Discord/GitHub/Twitch — os três já suportados via `IdentityProvider::supportedProviders()`) redireciona pro callback padrão do OAuth. -3. Novo `MobileOAuthController::callback` reaproveita `HandleOAuthCallbackAction::execute()` (mesma Action do fluxo web) pra resolver/criar o `User` — mas em vez de `Auth::login()`, gera um **código de troca de uso único** (curto, ~60s de TTL, guardado em cache) e redireciona pro deep link do app: `NATIVEPHP_DEEPLINK_SCHEME://oauth/callback?code=...`. -4. App recebe o deep link, extrai o `code`, faz `POST /api/mobile/auth/exchange` com esse código. -5. Endpoint valida o código (uso único, expira, invalida-se após o uso), emite `{ access_token, refresh_token, expires_in }` via `php-open-source-saver/jwt-auth`. +2. Provider (Discord/GitHub/Twitch — os três já suportados via `IdentityProvider::supportedProviders()`) redireciona pro callback do OAuth. +3. **Implementado diferente do rascunho inicial**: Discord/GitHub/Twitch só aceitam UMA `redirect_uri` fixa por app, cadastrada apontando pro callback web (`/auth/oauth/{provider}` → `OAuthController::getAuthenticate`) — não dava pra ter uma rota de callback mobile própria. O callback web compartilhado passou a distinguir por `OAuthIntent::MobileLogin` (lido do `state`) e, quando é esse o caso, gera um **código de troca de uso único** (curto, ~60s de TTL, guardado em cache) e redireciona pro deep link do app em vez de fazer `Auth::login()`. +4. App recebe o deep link (`he4rtapp://oauth/callback?code=...`), extrai o `code`, faz `POST /api/mobile/auth/exchange` com esse código. +5. Endpoint valida o código (uso único, expira, invalida-se após o uso — troca é atômica via `Cache::lock()`, não só `Cache::pull()`), emite `{ access_token, token_type, expires_in }` via `php-open-source-saver/jwt-auth`. **Por que um código de troca em vez do JWT direto no deep link:** deep links (e o histórico de URLs do SO) não são um lugar seguro pra um token de longa duração passar. O código de troca é de uso único e vive segundos — se vazar, não serve pra nada depois do primeiro uso. ### Refresh -`POST /api/mobile/auth/refresh` — refresh token válido troca por um novo par de access/refresh. `POST /api/mobile/auth/logout` invalida o refresh token atual (blacklist do próprio pacote). +**Implementado diferente do rascunho inicial**: não existe um `refresh_token` separado — `php-open-source-saver/jwt-auth` renova o próprio access token via `JWTGuard::refresh()`, aceitando um token já expirado desde que dentro da janela `jwt.refresh_ttl` e fora da blacklist. `POST /api/mobile/auth/refresh` manda o token atual no header `Authorization` (sem middleware `auth:api`, que rejeitaria um token expirado antes mesmo do controller rodar) e devolve um novo `{ access_token, token_type, expires_in }`. `POST /api/mobile/auth/logout` invalida o token atual via blacklist. ### Endpoints da Feature 0 | Método | Rota | Descrição | | ------ | -------------------------------------- | ------------------------------------------------------------------------------- | | GET | `/api/mobile/auth/{provider}/redirect` | Inicia OAuth (Discord/GitHub/Twitch) | -| GET | `/api/mobile/auth/{provider}/callback` | Callback do provider → gera código de troca → deep link | -| POST | `/api/mobile/auth/exchange` | Código de troca → par de tokens JWT | -| POST | `/api/mobile/auth/refresh` | Refresh token → novo par | -| POST | `/api/mobile/auth/logout` | Invalida o refresh token atual | +| POST | `/api/mobile/auth/exchange` | Código de troca → token JWT | +| POST | `/api/mobile/auth/refresh` | Token atual (mesmo expirado, dentro da janela) → token novo | +| POST | `/api/mobile/auth/logout` | Invalida o token atual (blacklist) | | GET | `/api/mobile/me` | Usuário autenticado (id, username, avatar) — já existe no PoC, só troca o guard | +Não existe rota de callback mobile própria — o callback do provider bate direto em `/auth/oauth/{provider}` (rota web já existente), ver passo 3 acima. + --- ## Feature 1 — Timeline @@ -166,3 +167,12 @@ v1 é só leitura (o PRD explicitamente escopa "visualização do próprio perfi 4. **Feature 2 (Eventos)** — a mais complexa (enrollment + dois métodos de check-in); decisão do gap de QR (seção acima) deveria estar fechada antes de começar. Cada feature vira sua própria branch/PR neste repo (`heartdevs.com`), seguindo a convenção `feature/` ou `story/531-` já documentada em `.ai/rules`. O client (`he4rt-app`) consome cada endpoint conforme ele fica pronto — não precisa esperar a API inteira pra começar a integrar a Feature 0/3. + +--- + +## PS: achados da revisão de segurança (CodeRabbit) + +A implementação da Feature 0 passou por revisão automática de segurança antes do merge, que achou dois pontos reais no fluxo de troca de código: + +- **Race condition na troca do código** (corrigido): `Cache::pull()` do Laravel é `get()` + `forget()` como duas chamadas separadas, não atômicas — duas requisições concorrentes com o mesmo código podiam ler o valor antes de qualquer uma apagar, mintando dois tokens da mesma autorização. `ExchangeMobileCodeAction` passou a usar `Cache::lock()` pra serializar leitura+remoção por código. +- **Deep link com custom scheme pode ser sequestrado** (débito técnico conhecido, não fechado nesta PR): `he4rtapp://oauth/callback?code=...` usa um esquema de URL customizado, que não é exclusivo do app — outro app instalado no mesmo aparelho pode registrar o mesmo scheme e interceptar o código antes do app legítimo (TTL curto e uso único não impedem isso, já que o atacante só precisa ser o primeiro a usar). A correção correta é **PKCE** (o app mobile gera um `code_verifier` local, manda só o hash `code_challenge` no redirect, e precisa do verifier original pra completar a troca depois) ou um HTTPS App Link verificado em vez do scheme customizado. Não implementado agora porque depende do `he4rt-app` (ainda só um scaffold) também mudar o lado dele — fica registrado aqui pra não ser esquecido antes do app mobile começar a consumir esse fluxo de verdade. From f48ff4166afb68ed2aac2a86bdaddeb1579f1393 Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 23 Sep 2026 22:13:20 -0300 Subject: [PATCH 7/8] =?UTF-8?q?refactor(identity):=20revis=C3=A3o=20do=20C?= =?UTF-8?q?linton=20no=20PR=20#565?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove comentário narrativo e o returnUrl 'mobile' morto — o fluxo mobile nunca chega a ler returnUrl, então null (já aceito pelo tipo) é mais honesto que uma string mágica sem uso. - getAuthenticate: centraliza a checagem de OAuthIntent::MobileLogin numa variável ($isMobile) calculada uma vez em vez de repetida em 3 pontos do método. --- .../Mobile/MobileOAuthController.php | 5 ----- .../Auth/Http/Controllers/OAuthController.php | 21 +++++++------------ 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php index 6f243d8a..0dfebdd5 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php @@ -46,15 +46,10 @@ public function redirect(string $provider): RedirectResponse throw_unless($client instanceof OAuthClientContract, NotFoundHttpException::class); - // Discord/GitHub/Twitch têm UMA redirect_uri fixa cadastrada (o callback - // web em /auth/oauth/{provider} — ver *OAuthClient::callbackUrl()). Por - // isso o retorno do provider sempre bate em OAuthController::getAuthenticate, - // nunca aqui; é ele quem finaliza o login mobile lendo intent=MobileLogin. $state = new OAuthStateDTO( intent: OAuthIntent::MobileLogin, provider: $identityProvider, panel: 'mobile', - returnUrl: 'mobile', ); return redirect()->to($client->redirectUrl($state)); diff --git a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php index 02b8bbf3..e59cacb2 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php @@ -54,18 +54,15 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act throw_if($identityProvider === null, NotFoundHttpException::class); $state = OAuthStateDTO::fromEncryptedString(request()->input('state')); + $isMobile = $state->intent === OAuthIntent::MobileLogin; $code = request()->input('code'); $oauthDenied = $code === null || request()->has('error'); if ($oauthDenied) { - if ($state->intent === OAuthIntent::MobileLogin) { - return redirect()->to(MobileOAuthDeepLink::build('error', 'access_denied')); - } - - $fallbackUrl = $state->returnUrl ?? '/'; - - return redirect()->to($fallbackUrl); + return $isMobile + ? redirect()->to(MobileOAuthDeepLink::build('error', 'access_denied')) + : redirect()->to($state->returnUrl ?? '/'); } try { @@ -73,14 +70,12 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act } catch (OAuthFlowException $oAuthFlowException) { Log::warning('OAuth flow failed', ['provider' => $provider, 'error' => $oAuthFlowException->getMessage()]); - if ($state->intent === OAuthIntent::MobileLogin) { - return redirect()->to(MobileOAuthDeepLink::build('error', 'oauth_flow_failed')); - } - - return redirect()->to($state->returnUrl ?? '/'); + return $isMobile + ? redirect()->to(MobileOAuthDeepLink::build('error', 'oauth_flow_failed')) + : redirect()->to($state->returnUrl ?? '/'); } - if ($result->intent === OAuthIntent::MobileLogin) { + if ($isMobile) { $exchangeCode = resolve(IssueMobileExchangeCodeAction::class)->execute($result->user); return redirect()->to(MobileOAuthDeepLink::build('callback', code: $exchangeCode)); From 4676d05143879f1f0e150bff3f96790f53c77822 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Mon, 28 Sep 2026 19:56:01 -0300 Subject: [PATCH 8/8] =?UTF-8?q?fix(bot-discord):=20acompanha=20a=20mudan?= =?UTF-8?q?=C3=A7a=20de=20namespace=20do=20Member=20e=20Role=20no=20discor?= =?UTF-8?q?d-php=2010.65?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O discord-php moveu Member para Discord\Parts\Guild\Member e Role para Discord\Parts\Guild\Role, deixando os nomes antigos como class_alias. O stub do PHPStan ainda descrevia as classes antigas, então o $user do Member virou mixed e quebrou a análise. - stub aponta para os namespaces novos - imports usam as classes reais em vez dos aliases deprecados - ignores do setRoles com o nome novo da classe --- app-modules/bot-discord/phpstan.ignore.neon | 4 +- .../Actions/Welcome/SendWelcomeDmAction.php | 2 +- .../src/Concerns/ResolvesGuildIcon.php | 2 +- .../bot-discord/src/DTO/WelcomeContextDTO.php | 2 +- .../bot-discord/src/Events/WelcomeMember.php | 2 +- .../src/SlashCommands/CargoDelasCommand.php | 4 +- .../src/SlashCommands/IntroductionCommand.php | 2 +- .../SlashCommands/SalaEmpresarialCommand.php | 2 +- .../bot-discord/stubs/discord-php.stub | 40 +++++++++++-------- 9 files changed, 33 insertions(+), 27 deletions(-) diff --git a/app-modules/bot-discord/phpstan.ignore.neon b/app-modules/bot-discord/phpstan.ignore.neon index bb33f1d5..1e376664 100644 --- a/app-modules/bot-discord/phpstan.ignore.neon +++ b/app-modules/bot-discord/phpstan.ignore.neon @@ -21,12 +21,12 @@ parameters: count: 1 path: src/Tasks/VoiceExperienceTask.php - - message: '#^Parameter \#1 \$roles of method Discord\\Parts\\User\\Member::setRoles\(\) expects#' + message: '#^Parameter \#1 \$roles of method Discord\\Parts\\Guild\\Member\\Member::setRoles\(\) expects#' identifier: argument.type count: 1 path: src/SlashCommands/CargoDelasCommand.php - - message: '#^Parameter \#1 \$roles of method Discord\\Parts\\User\\Member::setRoles\(\) expects#' + message: '#^Parameter \#1 \$roles of method Discord\\Parts\\Guild\\Member\\Member::setRoles\(\) expects#' identifier: argument.type count: 1 path: src/SlashCommands/IntroductionCommand.php diff --git a/app-modules/bot-discord/src/Actions/Welcome/SendWelcomeDmAction.php b/app-modules/bot-discord/src/Actions/Welcome/SendWelcomeDmAction.php index c8ff4e50..0b5e4493 100644 --- a/app-modules/bot-discord/src/Actions/Welcome/SendWelcomeDmAction.php +++ b/app-modules/bot-discord/src/Actions/Welcome/SendWelcomeDmAction.php @@ -4,7 +4,7 @@ namespace He4rt\BotDiscord\Actions\Welcome; -use Discord\Parts\User\Member; +use Discord\Parts\Guild\Member\Member; use He4rt\BotDiscord\DTO\WelcomeContextDTO; use He4rt\BotDiscord\Enums\DiscordErrorCode; use He4rt\BotDiscord\Welcome\WelcomeEmbedBuilder; diff --git a/app-modules/bot-discord/src/Concerns/ResolvesGuildIcon.php b/app-modules/bot-discord/src/Concerns/ResolvesGuildIcon.php index 2b71c33b..071c4821 100644 --- a/app-modules/bot-discord/src/Concerns/ResolvesGuildIcon.php +++ b/app-modules/bot-discord/src/Concerns/ResolvesGuildIcon.php @@ -4,7 +4,7 @@ namespace He4rt\BotDiscord\Concerns; -use Discord\Parts\User\Member; +use Discord\Parts\Guild\Member\Member; trait ResolvesGuildIcon { diff --git a/app-modules/bot-discord/src/DTO/WelcomeContextDTO.php b/app-modules/bot-discord/src/DTO/WelcomeContextDTO.php index ed137a1b..32db984b 100644 --- a/app-modules/bot-discord/src/DTO/WelcomeContextDTO.php +++ b/app-modules/bot-discord/src/DTO/WelcomeContextDTO.php @@ -4,7 +4,7 @@ namespace He4rt\BotDiscord\DTO; -use Discord\Parts\User\Member; +use Discord\Parts\Guild\Member\Member; final readonly class WelcomeContextDTO { diff --git a/app-modules/bot-discord/src/Events/WelcomeMember.php b/app-modules/bot-discord/src/Events/WelcomeMember.php index fb49d91d..d17a22e4 100644 --- a/app-modules/bot-discord/src/Events/WelcomeMember.php +++ b/app-modules/bot-discord/src/Events/WelcomeMember.php @@ -5,7 +5,7 @@ namespace He4rt\BotDiscord\Events; use Discord\Discord; -use Discord\Parts\User\Member; +use Discord\Parts\Guild\Member\Member; use Discord\WebSockets\Event as Events; use He4rt\BotDiscord\Actions\Welcome\AnnounceNewMemberAction; use He4rt\BotDiscord\Actions\Welcome\SendWelcomeDmAction; diff --git a/app-modules/bot-discord/src/SlashCommands/CargoDelasCommand.php b/app-modules/bot-discord/src/SlashCommands/CargoDelasCommand.php index 5b49340a..4f611077 100644 --- a/app-modules/bot-discord/src/SlashCommands/CargoDelasCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/CargoDelasCommand.php @@ -4,10 +4,10 @@ namespace He4rt\BotDiscord\SlashCommands; -use Discord\Parts\Guild\Role; +use Discord\Parts\Guild\Member\Member; +use Discord\Parts\Guild\Role\Role; use Discord\Parts\Interactions\Command\Option; use Discord\Parts\Interactions\Interaction; -use Discord\Parts\User\Member; class CargoDelasCommand extends AbstractSlashCommand { diff --git a/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php b/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php index 4f829231..fa7ba6bc 100644 --- a/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php @@ -6,7 +6,7 @@ use Discord\Builders\Components\TextInput; use Discord\Helpers\Collection; -use Discord\Parts\Guild\Role; +use Discord\Parts\Guild\Role\Role; use Discord\Parts\Interactions\Interaction; use He4rt\Identity\ExternalIdentity\DTOs\ResolveUserProviderDTO; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; diff --git a/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php index 3f8e8c06..1539d906 100644 --- a/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php @@ -5,7 +5,7 @@ namespace He4rt\BotDiscord\SlashCommands; use Discord\Parts\Channel\Channel; -use Discord\Parts\Guild\Role; +use Discord\Parts\Guild\Role\Role; use Discord\Parts\Interactions\Command\Option; use Discord\Parts\Interactions\Interaction; use Exception; diff --git a/app-modules/bot-discord/stubs/discord-php.stub b/app-modules/bot-discord/stubs/discord-php.stub index 4090adba..fe79377b 100644 --- a/app-modules/bot-discord/stubs/discord-php.stub +++ b/app-modules/bot-discord/stubs/discord-php.stub @@ -25,9 +25,6 @@ interface CollectionInterface namespace Discord\Parts\User; -use Discord\Helpers\CollectionInterface; -use Discord\Parts\Guild\Role; - /** * @property string $id * @property string $username @@ -42,6 +39,25 @@ class User { } +/** + * @property string $name + * @property int $type + * @property string|null $url + * @property string|null $application_id + * @property string|null $details + * @property string|null $state + */ +class Activity +{ +} + +namespace Discord\Parts\Guild\Member; + +use Discord\Helpers\CollectionInterface; +use Discord\Parts\Guild\Role\Role; +use Discord\Parts\User\Activity; +use Discord\Parts\User\User; + /** * @property string $id * @property User|null $user @@ -61,18 +77,6 @@ class Member { } -/** - * @property string $name - * @property int $type - * @property string|null $url - * @property string|null $application_id - * @property string|null $details - * @property string|null $state - */ -class Activity -{ -} - namespace Discord\Parts\Guild; /** @@ -86,13 +90,15 @@ namespace Discord\Parts\Guild; * @property int|null $member_count * @property mixed $members * @property mixed $channels - * @property \Discord\Helpers\CollectionInterface $roles + * @property \Discord\Helpers\CollectionInterface<\Discord\Parts\Guild\Role\Role> $roles * @property-read mixed $voice_states */ class Guild { } +namespace Discord\Parts\Guild\Role; + /** * @property string $id * @property string $name @@ -130,7 +136,7 @@ namespace Discord\Parts\WebSockets; * @property \Discord\Parts\Channel\Channel|null $channel * @property string $user_id * @property \Discord\Parts\User\User|null $user - * @property \Discord\Parts\User\Member|null $member + * @property \Discord\Parts\Guild\Member\Member|null $member * @property string $session_id * @property bool $deaf * @property bool $mute