Skip to content

feat(openapi): distinguish expired OAuth bearer from invalid token#38142

Open
GareArc wants to merge 1 commit into
mainfrom
worktree-token-expired-error-code
Open

feat(openapi): distinguish expired OAuth bearer from invalid token#38142
GareArc wants to merge 1 commit into
mainfrom
worktree-token-expired-error-code

Conversation

@GareArc

@GareArc GareArc commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What

Distinguishes an expired OAuth bearer token from an unknown/invalid one on the /openapi/v1 auth surface, so clients can tell session expired → re-authenticate apart from never authenticated.

Before: both collapsed to a generic 401, and an invalid bearer actually leaked a 500.

Changes

  • Resolver (api/libs/oauth_bearer.py): raises a distinct TokenExpiredError for expired DB rows and records a separate expired negative-cache marker, so a retry within the negative-cache TTL still reports expiry instead of degrading to a generic miss.
  • Auth pipeline (api/controllers/openapi/auth/pipeline.py): maps the two domain errors at the controller boundary to unified OpenApiError responses — SessionExpired (code token_expired) and InvalidBearer (code unauthorized), both 401. Also fixes the latent 500 on invalid bearers.
  • Contract: token_expired is synced through the codegen pipeline into the generated types.gen.ts / zod.gen.ts.
  • CLI (cli/src/http/error-mapper.ts): branches the 401 on the generated token_expired enum. The CLI's expired_token taxonomy member (RFC 8628 device-flow code expiry) is merged into token_expired; the RFC 8628 wire value is unchanged.

Tests

  • New resolver unit tests for the expiry verdict and cache-marker round-trip.
  • Pipeline + error-contract unit tests asserting token_expired vs unauthorized and the invalid→401 (not 500) fix.
  • Integration tests covering expired, expired-replay, and unknown-token paths.
  • CLI mapper test for the structured token_expired code.

Closes WTA-1062

Previously an expired OAuth bearer and an unknown/invalid one both
surfaced as an indistinguishable generic 401 (and an invalid token
actually leaked a 500), so a client could not tell "session expired,
re-authenticate" apart from "never authenticated."

The resolver now raises a distinct TokenExpiredError for expired DB
rows and records a separate `expired` negative-cache marker, so a
retry within the negative-cache TTL still reports expiry instead of
collapsing into a generic miss. The auth pipeline maps the two domain
errors to unified OpenApiError responses: SessionExpired (code
`token_expired`) and InvalidBearer (code `unauthorized`), both 401.
This also fixes the latent 500 on invalid bearers.

The `token_expired` code is synced through the contract codegen into
the generated types/zod, and the difyctl error mapper branches the
401 on it. The CLI `expired_token` taxonomy member (RFC 8628
device-flow code expiry) is merged into `token_expired`; the RFC 8628
wire value is unchanged.

Closes WTA-1062
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Jun 29, 2026
@github-actions github-actions Bot added the web This relates to changes on the web. label Jun 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Pyrefly Diff

base → PR
--- /tmp/pyrefly_base.txt	2026-06-29 04:46:58.776428699 +0000
+++ /tmp/pyrefly_pr.txt	2026-06-29 04:46:43.727343826 +0000
@@ -531,13 +531,23 @@
 ERROR `not in` is not supported between `Literal['sessions']` and `None` [not-iterable]
    --> tests/integration_tests/controllers/openapi/test_apps.py:210:12
 ERROR `None` is not subscriptable [unsupported-operation]
-  --> tests/integration_tests/controllers/openapi/test_auth.py:29:12
+  --> tests/integration_tests/controllers/openapi/test_auth.py:31:12
+ERROR `None` is not subscriptable [unsupported-operation]
+  --> tests/integration_tests/controllers/openapi/test_auth.py:32:12
+ERROR `None` is not subscriptable [unsupported-operation]
+  --> tests/integration_tests/controllers/openapi/test_auth.py:45:12
+ERROR `None` is not subscriptable [unsupported-operation]
+  --> tests/integration_tests/controllers/openapi/test_auth.py:47:12
+ERROR `None` is not subscriptable [unsupported-operation]
+  --> tests/integration_tests/controllers/openapi/test_auth.py:61:12
+ERROR `None` is not subscriptable [unsupported-operation]
+  --> tests/integration_tests/controllers/openapi/test_auth.py:74:12
 ERROR Argument `Literal['normal']` is not assignable to parameter `status` with type `SQLCoreOperations[TenantStatus] | TenantStatus` in function `models.account.Tenant.__init__` [bad-argument-type]
-  --> tests/integration_tests/controllers/openapi/test_auth.py:36:52
+  --> tests/integration_tests/controllers/openapi/test_auth.py:81:52
 ERROR Object of class `NoneType` has no attribute `get` [missing-attribute]
-  --> tests/integration_tests/controllers/openapi/test_auth.py:66:12
+   --> tests/integration_tests/controllers/openapi/test_auth.py:111:12
 ERROR Object of class `NoneType` has no attribute `get` [missing-attribute]
-  --> tests/integration_tests/controllers/openapi/test_auth.py:91:12
+   --> tests/integration_tests/controllers/openapi/test_auth.py:136:12
 ERROR Argument `_GP` is not assignable to parameter `graph_init_params` with type `GraphInitParams` in function `core.workflow.nodes.datasource.datasource_node.DatasourceNode.__init__` [bad-argument-type]
   --> tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py:85:27
 ERROR Argument `_GS` is not assignable to parameter `graph_runtime_state` with type `GraphRuntimeState` in function `core.workflow.nodes.datasource.datasource_node.DatasourceNode.__init__` [bad-argument-type]
@@ -2281,19 +2291,19 @@
 ERROR Object of class `FunctionType` has no attribute `view_class` [missing-attribute]
   --> tests/unit_tests/controllers/openapi/test_device_token.py:31:12
 ERROR Object of class `UnprocessableEntity` has no attribute `data` [missing-attribute]
-   --> tests/unit_tests/controllers/openapi/test_error_contract.py:105:9
+   --> tests/unit_tests/controllers/openapi/test_error_contract.py:106:9
 ERROR Object of class `UnprocessableEntity` has no attribute `data` [missing-attribute]
-   --> tests/unit_tests/controllers/openapi/test_error_contract.py:118:16
+   --> tests/unit_tests/controllers/openapi/test_error_contract.py:119:16
 ERROR Object of class `UnprocessableEntity` has no attribute `data` [missing-attribute]
-   --> tests/unit_tests/controllers/openapi/test_error_contract.py:122:9
+   --> tests/unit_tests/controllers/openapi/test_error_contract.py:123:9
 ERROR Object of class `UnprocessableEntity` has no attribute `data` [missing-attribute]
-   --> tests/unit_tests/controllers/openapi/test_error_contract.py:137:9
+   --> tests/unit_tests/controllers/openapi/test_error_contract.py:138:9
 ERROR No matching overload found for function `dict.__init__` called with arguments: (dict[str, Any] | None) [no-matching-overload]
-   --> tests/unit_tests/controllers/openapi/test_error_contract.py:146:20
+   --> tests/unit_tests/controllers/openapi/test_error_contract.py:147:20
 ERROR Object of class `Conflict` has no attribute `hint` [missing-attribute]
-   --> tests/unit_tests/controllers/openapi/test_error_contract.py:155:9
+   --> tests/unit_tests/controllers/openapi/test_error_contract.py:156:9
 ERROR No matching overload found for function `dict.__init__` called with arguments: (dict[str, Any] | None) [no-matching-overload]
-   --> tests/unit_tests/controllers/openapi/test_error_contract.py:220:48
+   --> tests/unit_tests/controllers/openapi/test_error_contract.py:221:48
 ERROR Object of class `NoneType` has no attribute `total_tokens` [missing-attribute]
   --> tests/unit_tests/controllers/openapi/test_models.py:13:12
 ERROR Argument `test_verify_approval_grant_raises_on_missing_field._FakeKeyset` is not assignable to parameter `keyset` with type `KeySet` in function `libs.jws.sign` [bad-argument-type]

@github-actions

Copy link
Copy Markdown
Contributor

Pyrefly Type Coverage

Metric Base PR Delta
Type coverage 51.48% 51.48% -0.00%
Strict coverage 51.00% 50.99% -0.00%
Typed symbols 30,824 30,834 +10
Untyped symbols 29,325 29,339 +14
Modules 2931 2932 +1

@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.36585% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.39%. Comparing base (eb4ec93) to head (bc68e02).
⚠️ Report is 23 commits behind head on main.

Files with missing lines Patch % Lines
api/libs/oauth_bearer.py 73.68% 4 Missing and 1 partial ⚠️
cli/src/http/error-mapper.ts 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #38142      +/-   ##
==========================================
+ Coverage   85.30%   85.39%   +0.09%     
==========================================
  Files        4931     5119     +188     
  Lines      256032   262689    +6657     
  Branches    48544    50082    +1538     
==========================================
+ Hits       218396   224331    +5935     
- Misses      33389    34066     +677     
- Partials     4247     4292      +45     
Flag Coverage Δ
api 85.50% <86.11%> (-0.07%) ⬇️
cli 88.43% <80.00%> (?)
dify-ui 94.93% <ø> (-0.01%) ⬇️
web 85.02% <ø> (+0.21%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@GareArc GareArc requested a review from wylswz June 29, 2026 05:01
@GareArc GareArc enabled auto-merge June 29, 2026 05:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M This PR changes 30-99 lines, ignoring generated files. web This relates to changes on the web.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant