rubis/apps/api/start/routes.ts
ordinarthur 5d3408fafa feat(api): refresh tokens custom (cookie httpOnly + rotation panic-mode)
Pattern hybride (cf. backend.md §7) : access token Bearer 30min en JSON + refresh token 30j en cookie httpOnly `rubis_refresh` géré custom au-dessus d'@adonisjs/auth qui ne ship pas de primitive refresh.

Migration refresh_tokens (uuid id, user_id FK CASCADE, hashed_token unique [SHA-256, 64 chars hex], expires_at, last_used_at nullable, revoked_at nullable, ip_address, user_agent). Index user_id + expires_at.

Service refresh_token.ts :
- issueRefreshToken(user, ctx) : génère 32 bytes random → base64url → hash SHA-256 stocké, plain dans le cookie httpOnly + secure (en prod) + sameSite strict + path=/api/v1/auth (le browser n'envoie le cookie que sur les routes auth, pas chaque requête API).
- consumeRefreshToken(ctx) : lookup par hash, validation expiry/revoked. Si on présente un token DÉJÀ révoqué, panic mode : tous les refresh tokens actifs du user sont invalidés (signal de vol — le vrai propriétaire devra se re-logger).
- revokeCurrentRefreshToken / revokeAllForUser pour logout et le panic.

Service auth_session.ts : factorise emitAuthSession(user, ctx) qui crée access + refresh + retourne l'AuthSession. Utilisé par signup / login / refresh — DRY.

Controllers :
- POST /auth/signup : emitAuthSession après tx (org + plans + user).
- POST /auth/login : emitAuthSession après verifyCredentials.
- POST /auth/refresh (nouveau) : consumeRefreshToken → emitAuthSession. Rotation : l'ancien token devient révoqué, le nouveau est posé. SPA-side : appelé au boot pour rehydrater + après 401 silencieux.
- POST /account/logout : User.accessTokens.delete + revokeCurrentRefreshToken + clearCookie.

CORS a déjà credentials: true → le cookie traverse cross-origin si origin allowed.

Bruno : nouvelle requête `Auth/04 Refresh.bru` + folder doc + flow décrit dans README. Bruno honore la cookie jar nativement, donc aucun setup additionnel pour tester.

⚠️ Le contrôleur Refresh est nouveau → le registre Tuyau-généré .adonisjs/server/controllers.ts sera régénéré au prochain `pnpm dev:api` (la regen est un effet de bord du boot Adonis, on ne peut pas la déclencher seule). Avant ce premier boot, `pnpm typecheck` échouera sur l'absence de `controllers.Refresh` dans le registre.
2026-05-06 15:05:06 +02:00

137 lines
4.7 KiB
TypeScript

/*
|--------------------------------------------------------------------------
| Routes file
|--------------------------------------------------------------------------
|
| Toutes les routes sont sous /api/v1/. Les groupes auth et account sont
| importés depuis le contrôleur généré par Tuyau pour garantir le contrat
| client typé (cf. docs/tech/backend.md §8).
|
*/
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('/', () => {
return { hello: 'world' }
})
router
.group(() => {
/**
* Auth — public. /refresh utilise le cookie httpOnly `rubis_refresh`
* posé par signup/login pour émettre une nouvelle AuthSession.
*/
router
.group(() => {
router.post('signup', [controllers.NewAccount, 'store']).as('signup')
router.post('login', [controllers.AccessTokens, 'store']).as('login')
router.post('refresh', [controllers.Refresh, 'handle']).as('refresh')
})
.prefix('auth')
.as('auth')
/**
* Compte courant — auth requise.
*/
router
.group(() => {
router.get('profile', [controllers.Profile, 'show']).as('profile.show')
router.patch('profile', [controllers.Profile, 'update']).as('profile.update')
router.post('logout', [controllers.AccessTokens, 'destroy']).as('logout')
})
.prefix('account')
.as('account')
.use(middleware.auth())
/**
* Organisation rattachée à l'utilisateur courant — auth requise.
*/
router
.group(() => {
router.get('me', [controllers.Organizations, 'show']).as('show')
router.patch('me', [controllers.Organizations, 'update']).as('update')
})
.prefix('organizations')
.as('organizations')
.use(middleware.auth())
/**
* Clients — auth requise, scope par organization de l'utilisateur courant.
*/
router
.group(() => {
router.get('', [controllers.Clients, 'index']).as('index')
router.post('', [controllers.Clients, 'store']).as('store')
router.get(':id', [controllers.Clients, 'show']).as('show').where('id', router.matchers.uuid())
router.patch(':id', [controllers.Clients, 'update']).as('update').where('id', router.matchers.uuid())
})
.prefix('clients')
.as('clients')
.use(middleware.auth())
/**
* Plans — auth requise. Lookup par slug (stable et lisible :
* /parametres/plans/standard-30j). POST plan custom = V2.
*/
router
.group(() => {
router.get('', [controllers.Plans, 'index']).as('index')
router.get(':slug', [controllers.Plans, 'show']).as('show')
router.patch(':slug', [controllers.Plans, 'update']).as('update')
})
.prefix('plans')
.as('plans')
.use(middleware.auth())
/**
* Invoices — auth requise. Ordre IMPORTANT : les routes statiques
* (/upload, /counts, /import-batch/...) sont déclarées AVANT /:id
* sinon `:id` matche les segments littéraux.
*/
router
.group(() => {
router.get('', [controllers.Invoices, 'index']).as('index')
router.post('', [controllers.Invoices, 'store']).as('store')
router.get('counts', [controllers.Invoices, 'counts']).as('counts')
// OCR / Import batch (cf. ImportBatchesController)
router.post('upload', [controllers.ImportBatches, 'upload']).as('upload')
router
.get('import-batch/:id', [controllers.ImportBatches, 'show'])
.as('import-batch.show')
.where('id', router.matchers.uuid())
router
.post('import-batch/:id/drafts/:draftId/validate', [
controllers.ImportBatches,
'validateDraft',
])
.as('import-batch.draft.validate')
.where('id', router.matchers.uuid())
.where('draftId', router.matchers.uuid())
router
.post('import-batch/:id/drafts/:draftId/skip', [
controllers.ImportBatches,
'skipDraft',
])
.as('import-batch.draft.skip')
.where('id', router.matchers.uuid())
.where('draftId', router.matchers.uuid())
router
.delete('import-batch/:id', [controllers.ImportBatches, 'destroy'])
.as('import-batch.destroy')
.where('id', router.matchers.uuid())
router.get(':id', [controllers.Invoices, 'show']).as('show').where('id', router.matchers.uuid())
router
.post(':id/mark-paid', [controllers.Invoices, 'markPaid'])
.as('mark-paid')
.where('id', router.matchers.uuid())
})
.prefix('invoices')
.as('invoices')
.use(middleware.auth())
})
.prefix('/api/v1')