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

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
This commit is contained in:
GareArc
2026-06-28 21:45:13 -07:00
parent eb4ec93cea
commit bc68e02711
15 changed files with 301 additions and 21 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ async function pollWithRetry(
function expired(): BaseError {
return new BaseError({
code: ErrorCode.ExpiredToken,
code: ErrorCode.TokenExpired,
message: 'code expired before authorization',
})
}
-1
View File
@@ -35,7 +35,6 @@ describe('error codes', () => {
[ErrorCode.AuthExpired, ExitCode.Auth],
[ErrorCode.TokenExpired, ExitCode.Auth],
[ErrorCode.AccessDenied, ExitCode.Auth],
[ErrorCode.ExpiredToken, ExitCode.Auth],
[ErrorCode.VersionSkew, ExitCode.VersionCompat],
[ErrorCode.UnsupportedEndpoint, ExitCode.VersionCompat],
[ErrorCode.ConfigSchemaUnsupported, ExitCode.VersionCompat],
-2
View File
@@ -3,7 +3,6 @@ export const ErrorCode = {
AuthExpired: 'auth_expired',
TokenExpired: 'token_expired',
AccessDenied: 'access_denied',
ExpiredToken: 'expired_token',
VersionSkew: 'version_skew',
UnsupportedEndpoint: 'unsupported_endpoint',
ConfigSchemaUnsupported: 'config_schema_unsupported',
@@ -40,7 +39,6 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
auth_expired: ExitCode.Auth,
token_expired: ExitCode.Auth,
access_denied: ExitCode.Auth,
expired_token: ExitCode.Auth,
version_skew: ExitCode.VersionCompat,
unsupported_endpoint: ExitCode.VersionCompat,
config_schema_unsupported: ExitCode.VersionCompat,
+15 -1
View File
@@ -33,7 +33,7 @@ describe('classifyResponse — canonical ErrorBody', () => {
expect(err.code).toBe(ErrorCode.Server4xxOther)
})
it('401 classifies by status as AuthExpired with CLI login hint', async () => {
it('401 unauthorized classifies as AuthExpired with CLI login hint', async () => {
const err = await classified(401, {
code: 'unauthorized',
message: 'session expired or revoked',
@@ -44,6 +44,20 @@ describe('classifyResponse — canonical ErrorBody', () => {
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
})
it('401 token_expired carries the structured TokenExpired code with the server message', async () => {
const err = await classified(401, {
code: 'token_expired',
message: 'Your session has expired.',
status: 401,
hint: 'Re-authenticate to continue (e.g. re-run your login command).',
})
expect(err.code).toBe(ErrorCode.TokenExpired)
expect(err.exit()).toBe(4)
expect(err.message).toBe('Your session has expired.')
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
})
it('unknown future server code is data, not behavior — status bucket decides', async () => {
const err = await classified(409, {
code: 'some_future_code',
+14 -4
View File
@@ -1,6 +1,6 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import type { ErrorCodeValue } from '@/errors/codes'
import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen'
import { zErrorBody, zOpenApiErrorCode } from '@dify/contracts/api/openapi/zod.gen'
import { BaseError, HttpClientError, newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { redactBearer } from './sanitize'
@@ -24,6 +24,16 @@ const AUTH_EXPIRED_CLASS: StatusClass = {
includeRaw: false,
}
// A 401 whose body carries the server's `token_expired` code is a known,
// behavior-driving signal (not opaque data): the session lapsed rather than the
// token being unknown/revoked, so wrappers get the distinct structured code.
const TOKEN_EXPIRED_CLASS: StatusClass = {
code: ErrorCode.TokenExpired,
fallbackMessage: () => 'session expired',
hint: AUTH_LOGIN_HINT,
includeRaw: false,
}
const SERVER_5XX_CLASS: StatusClass = {
code: ErrorCode.Server5xx,
fallbackMessage: status => `server error (HTTP ${status})`,
@@ -50,9 +60,9 @@ const ACCESS_DENIED_CLASS: StatusClass = {
includeRaw: false,
}
function statusClass(status: number): StatusClass {
function statusClass(status: number, serverError?: ErrorBody): StatusClass {
if (status === 401)
return AUTH_EXPIRED_CLASS
return serverError?.code === zOpenApiErrorCode.enum.token_expired ? TOKEN_EXPIRED_CLASS : AUTH_EXPIRED_CLASS
if (status === 403)
return ACCESS_DENIED_CLASS
if (status === 429)
@@ -87,7 +97,7 @@ export async function classifyResponse(request: Request, response: Response): Pr
const serverError = parseServerError(raw)
const status = response.status
const c = statusClass(status)
const c = statusClass(status, serverError)
return new HttpClientError({
code: c.code,
message: serverError?.message ?? c.fallbackMessage(status),