Skip to content

fix(auth): les jetons API generes depuis Reglages nauthentifient rien et ne persistent pas #782

Closed

Description

@alex-dembele

Relevé pendant #483 (PR #753), hors périmètre de ce ticket.

Problème

Dans Réglages, un utilisateur saisit un nom de jeton — le champ propose lui-même « ex. CI/CD » —
clique sur « Générer un jeton », et reçoit le message « Jeton créé et copié dans le presse-papiers ».

Il croit tenir une clé d'API utilisable dans sa CI. Il ne tient rien. Trois défauts se cumulent sur
ce seul parcours :

  1. Le jeton n'authentifie rien. Le middleware qui vérifie les jetons orsk_
    (backend/internal/middleware/tokenauth.go) n'est monté nulle part dans
    backend/cmd/server/main.go. Aucune requête portant ce jeton ne sera jamais acceptée.
  2. Le jeton ne survit pas à un redémarrage. TokenService conserve les jetons dans une map
    mémoire (backend/internal/service/token_service.go:29-31). Aucune migration ne crée la table
    api_tokens ; domain.APIToken n'est passé à AutoMigrate que dans
    backend/internal/handler/test_helpers.go:44, donc en test uniquement.
  3. Le presse-papiers ne reçoit pas le jeton. Le handler renvoie {"token": {..., "value": "orsk_…"}}
    (backend/internal/handler/token_handler.go:55-66), mais le client le type
    api.post<{ token?: string }> (frontend/src/features/settings/adminData.ts:74) et copie
    res.data?.token (frontend/src/features/settings/SettingsScreen.tsx:414-416). L'objet est
    truthy, donc writeText reçoit [object Object] et le toast annonce quand même la réussite.

Pendant ce temps, un second service de jetons, persistant et réellement branché sur
l'authentification, existe : PersonalAccessTokenService (GORM), exposé sur /auth/pat et monté
via middleware.PATMiddleware (backend/cmd/server/main.go:938, :1113-1115). L'interface
n'expose pas celui-là. Deux implémentations de la même idée, l'UI branchée sur la mauvaise.

C'est le défaut que docs/W0-05_DECEPTIVE_UI_AUDIT.md traque : l'interface affirme un succès qui
n'a pas eu lieu. Dans un produit de conformité vendu sur la non-surdéclaration, un client qui
découvre que le jeton de son pipeline n'a jamais rien authentifié n'en tire pas une conclusion
limitée à cet écran.

À noter pour le correctif : domain.APIToken ne porte pas de champ TenantID
(backend/internal/domain/api_token.go:36), et ListTokens ne filtre que sur UserID
(backend/internal/service/token_service.go:166-178). Toute persistance de ces jetons devra porter
le tenant_id et filtrer dessus — règle absolue n°2 de CLAUDE.md.

Critères d'acceptation

  1. Un jeton créé depuis Réglages authentifie une requête API réelle : un appel portant ce jeton sur
    un endpoint protégé répond 200, et le même appel sans jeton répond 401.
  2. Un jeton créé survit à un redémarrage du serveur, prouvé par un test d'intégration qui écrit puis
    relit depuis PostgreSQL.
  3. La valeur secrète effectivement placée dans le presse-papiers commence par orsk_ ; un test
    frontend couvre le succès et interdit la régression [object Object].
  4. Le jeton porte un tenant_id ; un jeton du tenant A est invisible et inutilisable depuis le
    tenant B, prouvé par un test cross-tenant.
  5. Il ne reste qu'une implémentation de jeton d'API. Si PersonalAccessTokenService est retenu,
    TokenService, tokenauth.go et les routes /tokens sont supprimés et l'UI est rebranchée sur
    /auth/pat ; si c'est l'inverse, la justification est écrite dans une ADR.
  6. Tant que le parcours n'est pas fonctionnel, l'écran n'annonce aucun succès — pas de toast
    « créé et copié » sur une opération qui ne produit pas de jeton utilisable.

Definition of Done

Un utilisateur qui génère un jeton depuis Réglages peut le coller dans un pipeline CI et appeler
l'API avec, après un redémarrage du serveur.

Activity

  1. added
    priority:P0Blocking: nothing else ships until this closes
    type:bugSomething is broken
    trustEvidence a buyer's CISO tests before features matter
    tier:0-trustTrust: security, isolation, evidence integrity
    on Sep 23, 2026
  2. added this to the trust-v1 milestone on Sep 23, 2026
  3. alex-dembele commented on Sep 25, 2026

    @alex-dembele
    MemberAuthor

    audit — 2026-09-25

    Triage: this issue is the canonical one for the API-token defect. #709 describes the same defect with fewer criteria; it is marked as a duplicate of this one. Anything #709 adds is kept here: nothing, since criteria 1-6 cover it.

    Labels completed (priority:P0-critical, status:ready). Placed in Sprint 1 of the launch board.

    Next: Sprint 1 of the launch board.
    Blocked on: nothing


    Generated by Claude Code

  4. added
    area:foundationProduct and platform foundations
    priority:P0-criticalProduction broken or exposed — work now
    status:readyMeets the ready definition
    and removed
    status:readyMeets the ready definition
    on Sep 25, 2026
  5. alex-dembele commented on Oct 1, 2026

    @alex-dembele
    MemberAuthor

    work — 2026-10-01

    Done — Claimed the issue, created branch 782-fixauth-les-jetons-api-generes-depuis-reglages-nauthentifient-rien-et-ne-persistent-pas from master (583a52b), and mapped both token stacks. No code changed yet: the session was halted by a local permission block on the first edits.

    Design chosen (criterion 5): keep PersonalAccessTokenService, delete TokenService. The PAT service is already DB-backed (domain.PersonalAccessToken is in cmd/server/schema.go AutoMigrate) and mounted (middleware.PATMiddleware, cmd/server/main.go:972; routes :1147-1149). No ADR is needed on this path.

    Plan, in order

    1. backend/internal/domain/membership.go — add TenantID uuid.UUID gorm:"type:uuid;index;not null" to PersonalAccessToken.
    2. backend/internal/infrastructure/database/prepare.go — new preparePersonalAccessTokenTenant, run before AutoMigrate: ADD COLUMN IF NOT EXISTS tenant_id uuid, backfill tenant_id = users.default_org_id, which is exactly what resolveSessionClaims resolves today, so behaviour is unchanged. Rows whose owner has no default org never authenticated (the resolver errors), so they can be dropped. Mirror this in migrations/0066_pat_tenant.up/down.sql.
    3. Repository and service (internal/infrastructure/repository/interfaces.go, the GORM PAT repo, internal/auth/pat.go): CreateToken(userID, tenantID, …), list/get/revoke/last-used filter on user_id AND tenant_id. GetByTokenHash stays unscoped by design (it is the authentication step), and the returned row carries the tenant. Note this in security/isolation/registry.go.
    4. Token format orsk_<8hex>_<64hex> (criterion 3). ValidateToken and looksLikePAT strip an optional orsk_ so legacy <8hex>_<secret> tokens keep working.
    5. PATMiddleware: resolve with the org resolver (resolveSessionForOrg(uid, pat.TenantID)), not the default-org one. A token from tenant A can then never act in tenant B, and a removed membership kills the token (criterion 4). A * scope means "everything the owner holds": today permsGrant(owner, "*") is false for a non-admin, so their full-access token would carry zero permissions.
    6. Remove the legacy stack: internal/service/token_service.go (+test), internal/middleware/tokenauth.go (+test), internal/handler/token_handler.go (+test, + token_flow_integration_test.go), internal/domain/api_token.go (+test), tokenService wiring main.go:278 and routes main.go:2093-2105, &domain.APIToken{} / "api_tokens" in internal/handler/test_helpers.go, "APIToken": "#709" in cmd/server/schema_test.go, /tokens entries in internal/handler/authz_route_coverage_test.go:83,154,155,183-187 and internal/security/isolation/registry.go:254,601.
    7. Frontend frontend/src/features/settings/adminData.ts: useTokens → GET/POST /auth/pat ({name, scopes:["*"]}), revoke → DELETE /auth/pat/:id; response type { token: string }. SettingsScreen.tsx TokensTab (≈l.409-427): show the secret once in a reveal panel with a Copy button, toast "copied" only when writeText resolves (criterion 6), and drop the dead revoked facet (PAT revoke is a hard delete).
    8. Tests: PAT service unit tests (Success/NotFound/Unauthorized), a Postgres integration test that creates a token, reopens a new *gorm.DB and authenticates with it (criterion 2), an end-to-end middleware test asserting 200 with the token / 401 without (criterion 1), a cross-tenant test (criterion 4), and a vitest test asserting the clipboard receives a value starting orsk_, never [object Object] (criterion 3).

    Verified — nothing yet; no code changed.
    Criteria — 1 ❌ · 2 ❌ · 3 ❌ · 4 ❌ · 5 ❌ · 6 ❌ (not started, see Blocked on)
    Next — Start at plan step 1 on the branch above.
    Blocked on — @owner: the local auto-mode classifier refused git rm of the legacy token files and then a one-field edit to internal/domain/membership.go. The owner needs to allow these edits/deletions (or run step 6's deletions by hand) before the work can continue.

  6. alex-dembele commented on Oct 1, 2026

    @alex-dembele
    MemberAuthor

    work — 2026-10-01

    Done — PR #853, four commits on 782-fixauth-….

    • backend/internal/domain/membership.go: PersonalAccessToken.TenantID (not null). Backfill from users.default_org_id in internal/infrastructure/database/prepare.go (backfillPersonalAccessTokenTenant, runs before AutoMigrate) and migrations/0067_personal_access_tokens_tenant.{up,down}.sql. 0066 is taken by security(auth): a TOTP code can be replayed within its validity window #849.
    • internal/infrastructure/repository/{interfaces.go,gorm_personal_access_token_repository.go}: ListByOwner, UpdateLastUsed and DeleteByOwner filter tenant_id AND user_id. GetByTokenHash is unscoped (the authentication step).
    • internal/auth/pat.go: orsk_<8-hex>_<secret> (legacy form still accepted), CreateToken(user, tenant, …), typed errors.
    • internal/middleware/pat_middleware.go: resolves with the org resolver for pat.TenantID (cmd/server/main.go passes resolveSessionForOrg). The "*" scope means the owner's permissions.
    • Deleted TokenService, tokenauth.go, token_handler.go, domain/api_token.go and their tests, the /tokens routes, and the matching entries in the authz coverage list, isolation registry and schema test.
    • Frontend: features/settings/ApiTokensPanel.tsx (extracted from SettingsScreen.tsx) on /auth/pat, one-time reveal panel, "copied" only when writeText resolves, optimistic revoke, zod name check. adminData.ts hooks retyped.
    • Docs: docs/API_SECURITY_GUIDE.md, docs/API_COMPLETE_ENDPOINTS.md.

    Verified

    • go vet ./... clean; go test ./... with Postgres gives 79 ok, 0 FAIL.
    • TestPAT_AuthenticatesProtectedRoute, TestPAT_CrossTenant, TestPATRepo_Postgres_SurvivesRestart, TestPATRepo_TenantIsolation and TestBackfillPersonalAccessTokenTenant_Postgres pass. Sabotage of the tenant predicate makes the isolation test fail.
    • vitest apiTokensPanel.test.tsx: 5/5. Sabotage of the secret handling makes the [object Object] test fail.
    • Live server: 200 with the token, 401 without, 401 tampered, still 200 after a server restart. From the browser, the token taken from the reveal panel got 200, and after revoking from the UI it got 401.

    Criteria — 1 ✅ · 2 ✅ · 3 ✅ · 4 ✅ (unit + Postgres; no live two-org boot) · 5 ✅ · 6 ✅
    Next — Owner review of PR #853. Separately, master's Settings route crashes because src/shared/ds/index.ts lost export { useSuccessFeedback } in merge e074589. That needs its own issue; it also explains 19 failing frontend tests on master.
    Blocked on — nothing

  7. alex-dembele commented on Oct 1, 2026

    @alex-dembele
    MemberAuthor

    work — 2026-10-01 (follow-up)

    Done — Live two-organization proof for criterion 4, posted on PR #853 (#853 (comment)). A token minted in org A keeps reading A only after its owner joins B and B becomes their default org. Once the owner's membership in A is revoked, the token gets 401 and does not fall back to B. Also opened #854 for the Settings crash on master.
    Verified — the outputs are in the PR comment; the run used a throwaway Postgres 18 database, now stopped.
    Criteria — 1 ✅ · 2 ✅ · 3 ✅ · 4 ✅ (unit, Postgres and live) · 5 ✅ · 6 ✅
    Next — Owner review of PR #853.
    Blocked on — nothing

  8. added a commit that references this issue on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:backendGo, /internal, /pkgarea:foundationProduct and platform foundationsarea:frontendReact, /srcpriority:P0Blocking: nothing else ships until this closespriority:P0-criticalProduction broken or exposed — work nowstatus:in-reviewPR opentier:0-trustTrust: security, isolation, evidence integritytrustEvidence a buyer's CISO tests before features mattertype:bugSomething is broken

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions