Skip to content

Refresh tokens against Okta

Field Value
Operation ID OP-12
Trigger JwtRefreshService.Refresh, called by a consumer (for example, the JwtRefresh application)
Inputs refreshToken, clientId/clientSecret
Outputs JwtSet, or JwtRefreshException
Primary components JwtRefreshService.Refresh
Entry point src/SAIF.NetCore.OIDC/src/SAIF.NetCore.OIDC/Services/JwtRefreshService.cs

Steps

  1. endpoint = OktaAuthorizationServerUrl + RefresnTokenUrl (property-name typo in code; e.g. /oauth2/default/v1/token) (BR-OIDC-23) — resolved via Resolve OIDC settings.
  2. Configure the HTTP client:
  3. 2.1. SetBasicAuthentication(clientId, clientSecret): Base64-encodes "{clientId}:{clientSecret}" and adds an Authorization: Basic <encoded> entry to the client's Headers dictionary.
  4. 2.2. SetClientInfo(OktaBaseUrl, HttpContext.User.Identity.Name, Environment.MachineName, Assembly.GetEntryAssembly().GetName().Name): stores the base URL and builds a ClientInfo (machine name, application ID, user name) used to populate caller-identity headers on every subsequent request from this instance.
  5. 2.3. Headers.Add("Content-Type", "application/x-www-form-urlencoded"), added to the same Headers dictionary as the Basic-auth header above.
  6. On failure: Dictionary.Add throws ArgumentException if a key with the same name already exists. CreateRequest clears Headers only when it actually runs — immediately after copying the dictionary's entries onto the outgoing RestRequest and before client.Execute(request) is called — unless PersistHeadersAfterRequest is set, which it is not here. If SetClientInfo throws before PostAsync/CreateRequest ever runs (for example because HttpContextAccessor.HttpContext is null outside an active HTTP request, or OktaBaseUrl is not a valid absolute URI for new Uri(baseUrl)), the "Authorization" entry added by SetBasicAuthentication in step 2.1 is never cleared; a subsequent sequential Refresh call on the same scoped IHttpJsonClient instance then collides in Headers.Add during its own step 2.1. A collision can also occur from an overridden HttpClientOptions with PersistHeadersAfterRequest = true, or from two concurrent calls racing on the same scoped instance before the first request's CreateRequest call clears its headers*.

    Origin: Step 2.1 is implemented in Configure authentication and step 2.2 is implemented in Initialize client (both SAIF.NetCore.HttpClients), executed on behalf of this operation.

  7. result = await PostAsync(endpoint + RefreshTokenQueryString + refreshToken). The grant is sent in the query string, and refreshToken is not URL-encoded (BR-OIDC-23).
  8. 3.1. PostAsync validates that ClientInfo and the base URI are set (throwing ClientNotInitializedException otherwise — not reachable here since step 2.2 always runs first), then ExecuteRestCall calls CreateRequest to build a RestSharp request: it copies the caller-identity headers from _clientInfo, any BearerToken, and the current contents of the Headers dictionary (including the Authorization/Content-Type headers set in step 2) onto the request, then — unless PersistHeadersAfterRequest is set — clears the Headers dictionary before client.Execute(request) sends it.
  9. 3.2. If the response status is one of the client's configured RetryOnStatusCodes (the standard injected IHttpJsonClient's default HttpClientOptions retries on 401 Unauthorized, 502 Bad Gateway, and 503 Service Unavailable) and retries remain (3 by default, for up to 4 total attempts), the client sleeps RetryDelayInMs (1000 ms by default) with a blocking Thread.Sleep, then calls ExecuteRestCall again, which calls CreateRequest a second time. Because step 3.1 already cleared the Headers dictionary on the first attempt, this rebuilt retry request carries only the identity headers and BearerToken (if set) — the custom Authorization/Content-Type headers added in step 2 are not present on any retried request. A consumer that calls SetOptions with a different HttpClientOptions before Refresh could override the retry defaults, but no such override is present in this library's own code*.
  10. 3.3. Once a non-retryable (or retry-exhausted) response is obtained, the client normalizes it into an IHttpRequestResult (status code, content, headers, success flag).

    Origin: Steps 3.1–3.2 are implemented in Execute request (SAIF.NetCore.HttpClients) and executed on behalf of this operation.

  11. If !result.IsSuccessful → throw JwtRefreshException(result.StatusCode, result.ErrorException) (BR-OIDC-24).
  12. On failure: exception propagates to the caller.
  13. r = result.GetResult<RefreshTokenResult>(). Return new JwtSet { AccessToken = r.AccessToken, IdToken = r.IdToken, ExpiresAt = DateTime.UtcNow.AddSeconds(r.ExpiresIn) } (BR-OIDC-25).
  14. 5.1. GetResult<T> checks IsSuccessful first (always true by the time step 5 runs, since step 4 already handled the failure case), then deserializes Content into RefreshTokenResult.
  15. On branch: the returned refresh_token is discarded, so there is no refresh-token rotation handling.

    Origin: Step 5.1 is implemented in Consume result (SAIF.NetCore.HttpClients) and executed on behalf of this operation.

Process flow

flowchart TD
    Start([Refresh called]) --> Endpoint["Compute endpoint: AuthorizationServerUrl + RefreshTokenUrl"]
    Endpoint --> Configure["Set basic auth, client info, Content-Type header"]
    Configure --> Post["POST endpoint + RefreshTokenQueryString + refreshToken"]
    Post --> Retry{Status retryable and retries remain?}
    Retry -->|Yes, up to 3 times| Post
    Retry -->|No| Success{result.IsSuccessful?}
    Success -->|No| Throw[Throw JwtRefreshException with status code]
    Success -->|Yes| Parse[GetResult RefreshTokenResult]
    Parse --> BuildSet["Build JwtSet: AccessToken, IdToken,<br>ExpiresAt = UtcNow + ExpiresIn"]
    BuildSet --> Done([Return JwtSet - rotated refresh token discarded])

Failure and edge-case behavior

  • A non-successful Okta response raises JwtRefreshException carrying the HTTP status code. The standard injected IHttpJsonClient implementation does retry automatically — up to 3 times, 1 second apart by default — when the response status is 401, 502, or 503; JwtRefreshException is only raised once retries are exhausted or the status is not one of the configured retryable codes. A caller-supplied IHttpJsonClient/HttpClientOptions could in principle disable or reconfigure this retry behavior, but no such override exists in this library's own code*.
  • The refresh grant is transmitted as an unencoded query-string parameter, which can leak the refresh token into access/proxy logs along the request path.
  • Okta's response can include a rotated refresh_token; this implementation discards it, so the original (possibly now-invalid, depending on Okta's rotation policy) refresh token remains in use by the caller*.
  • Calling Refresh twice in sequence on the same scoped IHttpJsonClient instance normally does not throw from Headers.Add, because CreateRequest clears custom headers immediately before the request is sent by default (PersistHeadersAfterRequest = false). A collision is possible if a consumer sets PersistHeadersAfterRequest = true, if two calls race concurrently on the same scoped instance before the first request's CreateRequest clears its headers, or if SetClientInfo throws (null HttpContext, or an invalid OktaBaseUrl) after SetBasicAuthentication has already added the Authorization header but before CreateRequest ever runs to clear it — leaving that header in place to collide with the next sequential call's Headers.Add*.
  • Retried requests (on 401/502/503, up to 3 times by default) do not carry the custom Authorization/Content-Type headers set in step 2, because CreateRequest clears the Headers dictionary on the first attempt before any retry is triggered; only the identity headers and BearerToken (if set) are present on a retried request.

Note: Statements marked with * were inferred from code structure, naming conventions, or contextual evidence where direct confirmation was not available in source artifacts. Statements marked with ** could not be confirmed; treat as unknown until verified.

Sources

  • src/SAIF.NetCore.OIDC/src/SAIF.NetCore.OIDC/Services/JwtRefreshService.cs
  • src/SAIF.NetCore.HttpClients/SAIF.NetCore.HttpClients/SAIF.NetCore.HttpClients/HttpJsonClient/HttpJsonClient.cs (consumer dependency; SetBasicAuthentication, SetClientInfo, CreateRequest, ExecuteRestCall retry/header behavior)
  • src/SAIF.NetCore.ServiceContracts/SAIF.NetCore.ServiceContracts/SAIF.NetCore.ServiceContracts/HttpClientOptions.cs (consumer dependency; default RetryOnStatusCodes/RetryDelayInMs/PersistHeadersAfterRequest values)