---
title: Refresh tokens against Okta
summary: Exchanges a refresh token for a new access/ID token pair at the Okta token endpoint.
status: draft
owner: SAIF Framework Platform Team
last_reviewed: 2026-10-02
library: SAIF.NetCore.OIDC
reference_type: package
operation_id: OP-12
trigger: JwtRefreshService.Refresh, called by a consumer (for example, the JwtRefresh application)
---

# 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](../business-rules.md#br-oidc-23-a-token-refresh-posts-to-a-composed-okta-endpoint-with-basic-auth)) — resolved via [Resolve OIDC settings](op-11-resolve-oidc-settings.md).
2. Configure the HTTP client:
   - 2.1. `SetBasicAuthentication(clientId, clientSecret)`: Base64-encodes `"{clientId}:{clientSecret}"` and adds an `Authorization: Basic <encoded>` entry to the client's `Headers` dictionary.
   - 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.
   - 2.3. `Headers.Add("Content-Type", "application/x-www-form-urlencoded")`, added to the same `Headers` dictionary as the Basic-auth header above.
   - 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`](../../saif-netcore-httpclients/operations/configure-authentication.md) and step 2.2 is implemented in [`Initialize client`](../../saif-netcore-httpclients/operations/initialize-client.md) (both `SAIF.NetCore.HttpClients`), executed on behalf of this operation.
3. `result = await PostAsync(endpoint + RefreshTokenQueryString + refreshToken)`. The grant is sent in the **query string**, and `refreshToken` is **not** URL-encoded ([BR-OIDC-23](../business-rules.md#br-oidc-23-a-token-refresh-posts-to-a-composed-okta-endpoint-with-basic-auth)).
   - 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.
   - 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*.
   - 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/operations/execute-request.md) (`SAIF.NetCore.HttpClients`) and executed on behalf of this operation.
4. If `!result.IsSuccessful` → throw `JwtRefreshException(result.StatusCode, result.ErrorException)` ([BR-OIDC-24](../business-rules.md#br-oidc-24-a-non-successful-refresh-raises-a-typed-exception)).
   - On failure: exception propagates to the caller.
5. `r = result.GetResult<RefreshTokenResult>()`. Return `new JwtSet { AccessToken = r.AccessToken, IdToken = r.IdToken, ExpiresAt = DateTime.UtcNow.AddSeconds(r.ExpiresIn) }` ([BR-OIDC-25](../business-rules.md#br-oidc-25-refreshed-expiry-is-computed-from-expires_in%3B-rotation-is-discarded)).
   - 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`.
   - 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/operations/consume-result.md) (`SAIF.NetCore.HttpClients`) and executed on behalf of this operation.

## Process flow

```mermaid
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.

## Related documentation
- [Operations and processes](../operations-processes.md)
- [Business rules](../business-rules.md)

> **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)
