Skip to content

Return GRPC statuses for more Okta errors. - #200

Merged
ggreer merged 1 commit into
mainfrom
ggreer/error-handling-cleanup
Aug 24, 2026
Merged

ggreer merged 1 commit into
mainfrom
ggreer/error-handling-cleanup

Conversation

@ggreer

@ggreer ggreer commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

All auth errors were returning an unknown error instead of a GRPC status, causing us to misclassify them. This change adds some okta errors besides notfound, and falls back to the response's http status if that lookup fails.

Comment thread pkg/connector/helpers.go
Comment on lines +153 to 157
// Fall back to http status code.
if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
grpcCode := uhttp.GrpcCodeFromHTTPStatus(resp.StatusCode)
return uhttp.WrapErrorsWithRateLimitInfo(grpcCode, resp.Response, err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (medium confidence): this HTTP-status fallback broadens codes.NotFound from "Okta returned E0000007/E0000008" to "any 404 response". ensureUserDeactivated, waitForUserDeprovisioned, and permanentlyDeleteUser (user.go:873/888/913/964/972) all treat codes.NotFound as proof the user is absent and return success, so a 404 that is not a genuine user-not-found — misrouted path, wrong org URL, or a proxy/WAF 404 with a non-JSON body (okta's CheckResponseForError still yields an *okta.Error with an empty ErrorCode) — now makes Delete() report a successful deprovision without anything being deleted. Consider restricting the not-found→absent inference in user.go to the Okta error codes, or only falling back to NotFound when the body actually parsed as an Okta error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good point!

Comment thread pkg/connector/helpers.go
Comment on lines +145 to +151
var oktaApiError *okta.Error
if errors.As(err, &oktaApiError) {
grpcErrCode, ok := oktaErrToGRPCError[oktaApiError.ErrorCode]
if ok {
return errors.Join(status.Error(grpcErrCode, oktaApiError.ErrorSummary), err)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (low confidence): the Okta error-code lookup now wins over the HTTP status, which removes the old unconditional resp.StatusCode >= 500 → codes.Unavailable. helpers_test.go:66-71 encodes this deliberately (500 + E0000007 → NotFound), so a 5xx that happens to carry one of the four mapped codes becomes non-retryable. Okta's real 5xx bodies use E0000009, so impact is low, but checking resp.StatusCode >= 500 before the code map would preserve retryability without changing any of the 4xx behavior this PR is adding.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the okta code is unknown to us, we fall back to using the http response, which maps 5xx to unavailable, which is a retryable error.

Comment thread pkg/connector/app.go
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: Return GRPC statuses for more Okta errors.

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 8ffb5fe10168.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness: handleOktaResponseError now maps four Okta error codes to gRPC codes and otherwise falls back to uhttp.GrpcCodeFromHTTPStatus + WrapErrorsWithRateLimitInfo, replacing convertNotFoundError/handleOktaResponseErrorWithNotFoundMessage. I verified the code survives the errors.Join + fmt.Errorf("%w") chain (grpc-go v1.83 status.CodeConvertFromError uses errors.As), that *okta.Response always carries a non-nil embedded *http.Response so the new resp.Response deref is safe, that every call site whose outer wrap was removed (app.go:146/151/176/215, group.go:149) has a callee that already wraps, and that the new helpers_test.go expectations match the SDK's actual 403→PermissionDenied / 429→Unavailable / 503→Unavailable mapping. Two findings from the previous review are addressed at this SHA — the listBindings nil deref (resource_sets.go:183) now has a resourceSetsBindings == nil guard, and the %!w(<nil>) default arm (app.go:154) now uses %s with bag.ResourceID(); the remaining open threads from that review are unchanged and are not repeated here. No go.mod/go.sum changes. One new suggestion below.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/group.go:203 — the getError failure path at line 190 still returns a bare, uncoded error for non-JSON error bodies and for 401/403 responses the Okta SDK returns without decoding the body.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/group.go`:
- Around line 188-191: inside the `case resourceTypeRole.Id:` error branch of `Grants`,
  `errOkta, err := getError(resp)` is followed by `return nil, nil, err`, which sends a
  bare uncoded error to the SDK. `getError` unmarshals the response body as JSON, so it
  fails when the body is not JSON (proxy/gateway 5xx HTML, empty body), and also on
  401/403 responses that carry a `Www-Authenticate: Bearer` header, because
  okta-sdk-golang v2's `CheckResponseForError` returns early in that case without
  decoding or restoring the body. In those cases the caller sees
  `unexpected end of JSON input` with `codes.Unknown` rather than the
  `Unavailable`/`Unauthenticated` classification this PR is introducing. Change the
  return to route through the new helper, e.g.
  `return nil, nil, fmt.Errorf("okta-connectorv2: failed to parse group roles error response: %w", handleOktaResponseError(resp, err))`,
  so the HTTP-status fallback still assigns a gRPC code.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@ggreer
ggreer force-pushed the ggreer/error-handling-cleanup branch from b7b4df4 to b4b351b Compare August 24, 2026 21:15
return nil, resp, err
}

return resourceSetBindings.Members, resp, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: resourceSetBindings stays nil when the request succeeds with an empty or null body — the Okta SDK's buildResponse returns early without decoding when len(bodyBytes) == 0, so resourceSetBindings.Members panics. Since this function is already being touched for error plumbing, a if resourceSetBindings == nil { return nil, resp, nil } guard would close that path (resource_sets.go:183 has the same shape with .Roles). Pre-existing, medium confidence — non-blocking.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@ggreer
ggreer force-pushed the ggreer/error-handling-cleanup branch from b4b351b to 3d8bc51 Compare August 24, 2026 21:43
Comment thread .github/workflows/ci.yaml Outdated
run: go build ./cmd/baton-okta
- name: Test grant/revoke
uses: ConductorOne/github-workflows/actions/sync-test@v4
uses: ConductorOne/github-workflows/actions/sync-test@c921d6cc32adab244b17a68109edacd884271557

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: c921d6cc32adab244b17a68109edacd884271557 is not on ConductorOne/github-workflows main and is not contained in the v4 tag (it compares as 1 ahead of both), so CI now runs an unmerged workflow revision that will never pick up future v4 updates. Consider merging that change and reverting these two uses: lines back to @v4 once the tag includes it.

resp, err := doRequest(ctx, reqUrl.String(), http.MethodGet, &resourceSetBindings, client)
if err != nil {
return nil, nil, err
return nil, resp, err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the newly-returned resp is only consumed by Grants (line 243). Revoke (line 352) still calls listMembersOfBinding with _ for the response and returns the bare error, and both Grant (line 318) and Revoke (line 373) format the underlying error with %s/err.Error(), which flattens the chain. Net effect: resource-set-binding provisioning failures still surface without a gRPC status, which is the behavior this PR is adding elsewhere. Threading resp through handleOktaResponseError and switching those two to %w would make provisioning consistent with the sync path.

Comment thread pkg/connector/app.go Outdated
applicationUsers, respCtx, err := listApplicationUsers(ctx, o.client, resource.Id.GetResource(), token, qp)
if err != nil {
return nil, nil, bag, convertNotFoundError(err, "okta-connectorv2: failed to list group users")
return nil, nil, bag, fmt.Errorf("okta-connectorv2: failed to list app users for app %s: %w", resource.Id.GetResource(), err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: listApplicationUsers (app.go:288) already wraps this error as okta-connectorv2: failed to fetch app users from okta: …, so the message becomes doubly prefixed. It is also inconsistent with the sibling listAppGroupGrants path just above (line 176), which now returns bare err. Same pattern at group.go:149, where listGroupUsers (group.go:318) already prefixes. Picking one convention would keep the messages readable.

t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := handleOktaResponseError(tt.resp, tt.err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: every call site wraps the result in fmt.Errorf("…: %w", handleOktaResponseError(...)), and user.go:873/888/913/964/972 then rely on status.Code(err) == codes.NotFound resolving through both that wrapper and the errors.Join inside this function. The table asserts on the unwrapped return only, so a future change to the wrapping (e.g. %v) would silently break deprovision/delete idempotency without failing a test. Consider one case that asserts the code survives fmt.Errorf("x: %w", got).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@ggreer
ggreer force-pushed the ggreer/error-handling-cleanup branch from 3d8bc51 to 040f15a Compare August 24, 2026 21:55
Comment thread pkg/connector/resource_sets.go Outdated
}

roles, respCtx, err := listBindings(ctx, rs.client, resource.Id.Resource)
roles, oktaResp, err := listBindings(ctx, rs.client, resource.Id.Resource)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (medium confidence): listBindings (line 183) dereferences resourceSetsBindings.Roles while the variable stays nil whenever the request succeeds with an empty or null body — the Okta SDK's buildResponse returns early without decoding when len(bodyBytes) == 0, so this Grants call panics instead of returning zero grants. Same pattern already flagged at resource_sets_bindings.go:166; guard both with if resourceSetsBindings == nil { return nil, resp, nil }.

Comment thread pkg/connector/app.go Outdated
return nil, nil, err
}
default:
return nil, nil, fmt.Errorf("okta-connectorv2: unexpected resource for app: %w", err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: err is guaranteed nil here — it was set by parsePageToken on line 129 and returned on line 130, and the default arm is reached without any intervening assignment. So %w formats as %!w(<nil>) and the resulting error carries no cause and no gRPC status. Since this PR is specifically about error classification, consider status.Errorf(codes.Internal, "okta-connectorv2: unexpected app grant type %q", bag.ResourceID()).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@ggreer
ggreer force-pushed the ggreer/error-handling-cleanup branch from 040f15a to 768a41c Compare August 24, 2026 22:43
@ggreer
ggreer force-pushed the ggreer/error-handling-cleanup branch from 768a41c to dba68b4 Compare August 24, 2026 22:46
Comment thread pkg/connector/group.go
return nil, &sdkResource.SyncOpResults{NextPageToken: pageToken}, nil
} else {
return nil, nil, convertNotFoundError(&errOkta, "okta-connectorv2: failed to list group roles")
return nil, nil, fmt.Errorf("okta-connectorv2: failed to list group roles: %w", handleOktaResponseError(resp, &errOkta))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (medium confidence): this line now routes the parsed Okta error through handleOktaResponseError, but the getError failure path just above (line 190, return nil, nil, err) still escapes uncoded. getError fails whenever the error body is not JSON — proxy/gateway 5xx HTML, empty body — and on 401/403 responses carrying Www-Authenticate: Bearer the Okta SDK returns the error without decoding the body at all, so json.Unmarshal on it fails too. Those cases surface as a bare unexpected end of JSON input with codes.Unknown instead of the Unavailable/Unauthenticated this PR aims to produce; wrapping line 190 as handleOktaResponseError(resp, err) would close the gap.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@ggreer
ggreer merged commit 09978fa into main Aug 24, 2026
11 checks passed
@ggreer
ggreer deleted the ggreer/error-handling-cleanup branch August 24, 2026 23:35
agustin-conductor added a commit that referenced this pull request Aug 26, 2026
Okta answers a bad DPoP proof with a 400, a zero-length body, and the reason
in a DPoP-scheme WWW-Authenticate header. The vendored SDK's
CheckResponseForError reads that header only for 401/403 responses whose
scheme is Bearer, then discards the decode failure on the empty body, so every
such rejection reached the caller as "the API returned an unknown error" and
failed the whole sync with no way to tell what happened.

Confirmed live against a DPoP-enabled tenant. A replayed proof jti, an iat
more than five minutes old, and an iat in the future all produce that exact
shape; an unsolicited nonce is accepted, and no resource response ever offers
a DPoP-Nonce, so the resource server issues no nonce challenges at all.

oktaauth:
- rewrite an empty-bodied rejection's body from the WWW-Authenticate params so
  the SDK renders Okta's actual reason
- retry once with a fresh proof on proof replay specifically; uhttp's transport
  retries with headers untouched, so a stale-connection retry can resend the
  same jti. A generic invalid_dpop_proof is not retried, since a skewed clock
  would only triple the traffic
- warn on any DPoP-related 4xx, logging the full challenge before the SDK
  discards it
- match a nonce challenge on 400 as well as 401, and in the JSON-body shape as
  well as the header, with a bounded retry loop. Defensive: unreachable on the
  tenants observed, but correct per RFC 9449 and consistent with the token
  endpoint's existing handling

connector:
- getError now names the HTTP status and excerpts an undecodable body. Its five
  call sites all return its error verbatim, so an empty body surfaced as a bare
  "unexpected end of JSON input" with no status and no prefix. #200 rewrote
  handleOktaResponseError but left getError untouched.
- the event feed no longer returns the SDK error bare, so it picks up a grpc
  code and the status line like every other call site. #200 did not touch
  event_log.go.

Status codes and grpc codes for everything routed through
handleOktaResponseError, including 429, come from #200's
GrpcCodeFromHTTPStatus fallback; no duplicate handling is added here.

Validated with three clean full syncs against a live tenant (119 actions, no
errors) plus unit coverage of each path using the captured challenge strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
agustin-conductor added a commit that referenced this pull request Aug 27, 2026
Okta answers a bad DPoP proof with a 400, a zero-length body, and the reason
in a DPoP-scheme WWW-Authenticate header. The vendored SDK's
CheckResponseForError reads that header only for 401/403 responses whose
scheme is Bearer, then discards the decode failure on the empty body, so every
such rejection reached the caller as "the API returned an unknown error" and
failed the whole sync with no way to tell what happened.

Confirmed live against a DPoP-enabled tenant. A replayed proof jti, an iat
more than five minutes old, and an iat in the future all produce that exact
shape; an unsolicited nonce is accepted, and no resource response ever offers
a DPoP-Nonce, so the resource server issues no nonce challenges at all.

oktaauth:
- rewrite an empty-bodied rejection's body from the WWW-Authenticate params so
  the SDK renders Okta's actual reason. Scoped to client errors and to the DPoP
  challenge within the header: RFC 9110 permits several challenges in one value,
  and a body outside 4xx must never be replaced
- retry once with a fresh proof on proof replay specifically; uhttp's transport
  retries with headers untouched, so a stale-connection retry can resend the
  same jti. A generic invalid_dpop_proof is not retried, since a skewed clock
  would only triple the traffic
- log any DPoP-related 4xx at debug, with the full challenge, before the SDK
  discards it. Debug not warn: a skewed clock is not retried, so warn would
  fire for every request in a sync, and the reason now reaches the caller in
  the error itself
- match a nonce challenge on 400 as well as 401, and in the JSON-body shape as
  well as the header, with a bounded retry loop. Defensive: unreachable on the
  tenants observed, but correct per RFC 9449 and consistent with the token
  endpoint's existing handling

connector:
- getError now names the HTTP status and excerpts an undecodable body, trimming
  to a rune boundary so the excerpt stays valid UTF-8. Its five call sites all
  return its error verbatim, so an empty body surfaced as a bare "unexpected
  end of JSON input" with no status and no prefix. #200 rewrote
  handleOktaResponseError but left getError untouched.
- the event feed no longer returns the SDK error bare, so it picks up a grpc
  code and the status line like every other call site. #200 did not touch
  event_log.go.

Status codes and grpc codes for everything routed through
handleOktaResponseError, including 429, come from #200's
GrpcCodeFromHTTPStatus fallback; no duplicate handling is added here.

Validated with three clean full syncs against a live tenant (119 actions, no
errors) plus unit coverage of each path using the captured challenge strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
agustin-conductor added a commit that referenced this pull request Aug 27, 2026
Okta answers a bad DPoP proof with a 400, a zero-length body, and the reason
in a DPoP-scheme WWW-Authenticate header. The vendored SDK's
CheckResponseForError reads that header only for 401/403 responses whose
scheme is Bearer, then discards the decode failure on the empty body, so every
such rejection reached the caller as "the API returned an unknown error" and
failed the whole sync with no way to tell what happened.

Confirmed live against a DPoP-enabled tenant. A replayed proof jti, an iat
more than five minutes old, and an iat in the future all produce that exact
shape; an unsolicited nonce is accepted, and no resource response ever offers
a DPoP-Nonce, so the resource server issues no nonce challenges at all.

oktaauth:
- restate the WWW-Authenticate challenge as the error body the SDK expects, so
  it renders what Okta actually said. The challenge is passed through whole
  rather than parsed into auth-params: RFC 9110 allows several challenges in
  one header, attributing a param to the right scheme needs a real parser, and
  quoting the header drops nothing and cannot misattribute one scheme's error
  to another. Bounded to client errors with an empty body, so a real body is
  never replaced.
- retry once with a fresh proof on proof replay specifically; uhttp's transport
  retries with headers untouched, so a stale-connection retry can resend the
  same jti. A generic invalid_dpop_proof is not retried, since a skewed clock
  would only triple the traffic
- log any DPoP-related 4xx at Warn with the full challenge, before the SDK
  discards it, logarithmically sampled with a total_occurrences field: a
  skewed clock is not retried, so an unsampled line would fire once per
  request for a whole sync
- match a nonce challenge on 400 as well as 401, and in the JSON-body shape as
  well as the header, with a bounded retry loop. Defensive: unreachable on the
  tenants observed, but correct per RFC 9449 and consistent with the token
  endpoint's existing handling

connector:
- getError now names the HTTP status and excerpts an undecodable body, trimming
  to a rune boundary so the excerpt stays valid UTF-8. Its five call sites all
  return its error verbatim, so an empty body surfaced as a bare "unexpected
  end of JSON input" with no status and no prefix. #200 rewrote
  handleOktaResponseError but left getError untouched.
- the event feed no longer returns the SDK error bare, so it picks up a grpc
  code and the status line like every other call site. #200 did not touch
  event_log.go.

Status codes and grpc codes for everything routed through
handleOktaResponseError, including 429, come from #200's
GrpcCodeFromHTTPStatus fallback; no duplicate handling is added here.

Validated with three clean full syncs against a live tenant (119 actions, no
errors) plus unit coverage of each path using the captured challenge strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
agustin-conductor added a commit that referenced this pull request Aug 27, 2026
Okta answers a bad DPoP proof with a 400, a zero-length body, and the reason
in a DPoP-scheme WWW-Authenticate header. The vendored SDK's
CheckResponseForError reads that header only for 401/403 responses whose
scheme is Bearer, then discards the decode failure on the empty body, so every
such rejection reached the caller as "the API returned an unknown error" and
failed the whole sync with no way to tell what happened.

Confirmed live against a DPoP-enabled tenant. A replayed proof jti, an iat
more than five minutes old, and an iat in the future all produce that exact
shape; an unsolicited nonce is accepted, and no resource response ever offers
a DPoP-Nonce, so the resource server issues no nonce challenges at all.

oktaauth:
- restate the WWW-Authenticate challenge as the error body the SDK expects, so
  it renders what Okta actually said. The challenge is passed through whole
  rather than parsed into auth-params: RFC 9110 allows several challenges in
  one header, attributing a param to the right scheme needs a real parser, and
  quoting the header drops nothing and cannot misattribute one scheme's error
  to another. Bounded to client errors with an empty body, so a real body is
  never replaced.
- retry once with a fresh proof when Okta reports a replayed proof jti; uhttp's
  transport retries a failed request with its headers untouched, so a
  stale-connection retry resends the same proof. Matched on the full
  DPoP-specific phrase, not on "already been used" alone, which another
  scheme's challenge in the same header could carry. A generic
  invalid_dpop_proof is not retried, since a skewed clock would only triple
  the traffic.
- log any DPoP-related 4xx at Warn with the full challenge, before the SDK
  discards it, logarithmically sampled with a total_occurrences field: a
  skewed clock is not retried, so an unsampled line would fire once per
  request for a whole sync
- guard the retry drain against a nil response body. net/http always sets one,
  but io.Copy on a nil ReadCloser panics and the two neighbouring helpers
  already check it.
- match a nonce challenge on 400 as well as 401, and in the JSON-body shape as
  well as the header, with a bounded retry loop. Defensive: unreachable on the
  tenants observed, but correct per RFC 9449 and consistent with the token
  endpoint's existing handling

connector:
- getError now names the HTTP status and excerpts an undecodable body. Its five
  call sites all return its error verbatim, so an empty body surfaced as a bare
  "unexpected end of JSON input" with no status and no prefix. #200 rewrote
  handleOktaResponseError but left getError untouched.
- the event feed no longer returns the SDK error bare, so it picks up a grpc
  code and the status line like every other call site. #200 did not touch
  event_log.go.

Both truncation paths back up to a rune boundary rather than cutting at a byte
offset, so an excerpt reaching an error message or a log field stays valid UTF-8.

Status codes and grpc codes for everything routed through
handleOktaResponseError, including 429, come from #200's
GrpcCodeFromHTTPStatus fallback; no duplicate handling is added here.

Validated with three clean full syncs against a live tenant (119 actions, no
errors) plus unit coverage of each path using the captured challenge strings.
The panic guard and both rune-boundary cuts are verified by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
agustin-conductor added a commit that referenced this pull request Aug 27, 2026
Okta answers a bad DPoP proof with a 400, a zero-length body, and the reason
in a DPoP-scheme WWW-Authenticate header. The vendored SDK's
CheckResponseForError reads that header only for 401/403 responses whose
scheme is Bearer, then discards the decode failure on the empty body, so every
such rejection reached the caller as "the API returned an unknown error" and
failed the whole sync with no way to tell what happened.

Confirmed live against a DPoP-enabled tenant. A replayed proof jti, an iat
more than five minutes old, and an iat in the future all produce that exact
shape; an unsolicited nonce is accepted, and no resource response ever offers
a DPoP-Nonce, so the resource server issues no nonce challenges at all.

oktaauth:
- restate the WWW-Authenticate challenge as the error body the SDK expects, so
  it renders what Okta actually said. The challenge is passed through whole
  rather than parsed into auth-params: RFC 9110 allows several challenges in
  one header, attributing a param to the right scheme needs a real parser, and
  quoting the header drops nothing and cannot misattribute one scheme's error
  to another. Bounded to client errors with a genuinely empty body, so a real
  body is never replaced -- including a body that failed partway through being
  read, whose bytes are reported rather than mistaken for an empty body.
- retry once with a fresh proof when Okta reports a replayed proof jti; uhttp's
  transport retries a failed request with its headers untouched, so a
  stale-connection retry resends the same proof. Matched on the full
  DPoP-specific phrase, not on "already been used" alone, which another
  scheme's challenge in the same header could carry. A generic
  invalid_dpop_proof is not retried, since a skewed clock would only triple
  the traffic.
- log any DPoP-related 4xx at Warn with the full challenge, before the SDK
  discards it, logarithmically sampled with a total_occurrences field: a
  skewed clock is not retried, so an unsampled line would fire once per
  request for a whole sync
- guard the retry drain against a nil response body. net/http always sets one,
  but io.Copy on a nil ReadCloser panics and the two neighbouring helpers
  already check it.
- match a nonce challenge on 400 as well as 401, and in the JSON-body shape as
  well as the header, with a bounded retry loop. Defensive: unreachable on the
  tenants observed, but correct per RFC 9449 and consistent with the token
  endpoint's existing handling

connector:
- getError now names the HTTP status, excerpts an undecodable body, and carries
  a grpc code derived from that status. Its five call sites return its error
  verbatim, so an empty body surfaced as a bare "unexpected end of JSON input"
  with no status and no prefix, and an unparseable body reached the sync as
  codes.Unknown while a parseable one became PermissionDenied for the same
  upstream failure. #200 rewrote handleOktaResponseError but left getError
  untouched.
- the event feed no longer returns the SDK error bare, so it picks up a grpc
  code and the status line like every other call site. #200 did not touch
  event_log.go.

Both truncation paths back up to a rune boundary rather than cutting at a byte
offset, so an excerpt reaching an error message or a log field stays valid UTF-8.

Status codes and grpc codes for everything routed through
handleOktaResponseError, including 429, come from #200's
GrpcCodeFromHTTPStatus fallback; no duplicate handling is added here.

Validated with four clean full syncs against a live tenant (119 actions, no
errors) plus unit coverage of each path using the captured challenge strings.
The panic guard, the partial-read fix, and both rune-boundary cuts are verified
by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
agustin-conductor added a commit that referenced this pull request Aug 31, 2026
…02) (#202)

* fix: surface DPoP proof rejections instead of "unknown error" (CXP-1002)

Okta answers a bad DPoP proof with a 400, a zero-length body, and the reason
in a DPoP-scheme WWW-Authenticate header. The vendored SDK's
CheckResponseForError reads that header only for 401/403 responses whose
scheme is Bearer, then discards the decode failure on the empty body, so every
such rejection reached the caller as "the API returned an unknown error" and
failed the whole sync with no way to tell what happened.

Confirmed live against a DPoP-enabled tenant. A replayed proof jti, an iat
more than five minutes old, and an iat in the future all produce that exact
shape; an unsolicited nonce is accepted, and no resource response ever offers
a DPoP-Nonce, so the resource server issues no nonce challenges at all.

oktaauth:
- restate the WWW-Authenticate challenge as the error body the SDK expects, so
  it renders what Okta actually said. The challenge is passed through whole
  rather than parsed into auth-params: RFC 9110 allows several challenges in
  one header, attributing a param to the right scheme needs a real parser, and
  quoting the header drops nothing and cannot misattribute one scheme's error
  to another. Bounded to client errors with a genuinely empty body, so a real
  body is never replaced -- including a body that failed partway through being
  read, whose bytes are reported rather than mistaken for an empty body.
- retry once with a fresh proof when Okta reports a replayed proof jti; uhttp's
  transport retries a failed request with its headers untouched, so a
  stale-connection retry resends the same proof. Matched on the full
  DPoP-specific phrase, not on "already been used" alone, which another
  scheme's challenge in the same header could carry. A generic
  invalid_dpop_proof is not retried, since a skewed clock would only triple
  the traffic.
- log any DPoP-related 4xx at Warn with the full challenge, before the SDK
  discards it, logarithmically sampled with a total_occurrences field: a
  skewed clock is not retried, so an unsampled line would fire once per
  request for a whole sync
- guard the retry drain against a nil response body. net/http always sets one,
  but io.Copy on a nil ReadCloser panics and the two neighbouring helpers
  already check it.
- match a nonce challenge on 400 as well as 401, and in the JSON-body shape as
  well as the header, with a bounded retry loop. Defensive: unreachable on the
  tenants observed, but correct per RFC 9449 and consistent with the token
  endpoint's existing handling

connector:
- getError now names the HTTP status, excerpts an undecodable body, and carries
  a grpc code derived from that status. Its five call sites return its error
  verbatim, so an empty body surfaced as a bare "unexpected end of JSON input"
  with no status and no prefix, and an unparseable body reached the sync as
  codes.Unknown while a parseable one became PermissionDenied for the same
  upstream failure. #200 rewrote handleOktaResponseError but left getError
  untouched.
- the event feed no longer returns the SDK error bare, so it picks up a grpc
  code and the status line like every other call site. #200 did not touch
  event_log.go.

Both truncation paths back up to a rune boundary rather than cutting at a byte
offset, so an excerpt reaching an error message or a log field stays valid UTF-8.

Status codes and grpc codes for everything routed through
handleOktaResponseError, including 429, come from #200's
GrpcCodeFromHTTPStatus fallback; no duplicate handling is added here.

Validated with four clean full syncs against a live tenant (119 actions, no
errors) plus unit coverage of each path using the captured challenge strings.
The panic guard, the partial-read fix, and both rune-boundary cuts are verified
by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: cut the token-endpoint error excerpt on a rune boundary too

formatTokenError excerpts an unparseable token-endpoint body the same way the
resource path does, and had the same defect this branch fixes elsewhere: a byte
slice at errorBodyExcerptLimit can split a multi-byte rune and leave invalid
UTF-8 in the returned gRPC message. It sits in the same package as the
truncateAtRuneBoundary helper added here, so the guard was being shipped with a
live instance of the bug beside it.

Point it at the helper and pin it with a test: restoring the byte slice fails
with `desc = token endpoint 502 Bad Gateway: EUR...\xe2\x82...`.

Reached whenever the body carries no error/error_description for parseTokenError
to pick up -- a non-JSON page from a proxy, say -- which is the same likelihood
as the resource-side case already covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants