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¶
endpoint = OktaAuthorizationServerUrl + RefresnTokenUrl(property-name typo in code; e.g./oauth2/default/v1/token) (BR-OIDC-23) — resolved via Resolve OIDC settings.- Configure the HTTP client:
- 2.1.
SetBasicAuthentication(clientId, clientSecret): Base64-encodes"{clientId}:{clientSecret}"and adds anAuthorization: Basic <encoded>entry to the client'sHeadersdictionary. - 2.2.
SetClientInfo(OktaBaseUrl, HttpContext.User.Identity.Name, Environment.MachineName, Assembly.GetEntryAssembly().GetName().Name): stores the base URL and builds aClientInfo(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 sameHeadersdictionary as the Basic-auth header above. - On failure:
Dictionary.AddthrowsArgumentExceptionif a key with the same name already exists.CreateRequestclearsHeadersonly when it actually runs — immediately after copying the dictionary's entries onto the outgoingRestRequestand beforeclient.Execute(request)is called — unlessPersistHeadersAfterRequestis set, which it is not here. IfSetClientInfothrows beforePostAsync/CreateRequestever runs (for example becauseHttpContextAccessor.HttpContextisnulloutside an active HTTP request, orOktaBaseUrlis not a valid absolute URI fornew Uri(baseUrl)), the"Authorization"entry added bySetBasicAuthenticationin step 2.1 is never cleared; a subsequent sequentialRefreshcall on the same scopedIHttpJsonClientinstance then collides inHeaders.Addduring its own step 2.1. A collision can also occur from an overriddenHttpClientOptionswithPersistHeadersAfterRequest = true, or from two concurrent calls racing on the same scoped instance before the first request'sCreateRequestcall clears its headers*.Origin: Step 2.1 is implemented in
Configure authenticationand step 2.2 is implemented inInitialize client(bothSAIF.NetCore.HttpClients), executed on behalf of this operation. result = await PostAsync(endpoint + RefreshTokenQueryString + refreshToken). The grant is sent in the query string, andrefreshTokenis not URL-encoded (BR-OIDC-23).- 3.1.
PostAsyncvalidates thatClientInfoand the base URI are set (throwingClientNotInitializedExceptionotherwise — not reachable here since step 2.2 always runs first), thenExecuteRestCallcallsCreateRequestto build a RestSharp request: it copies the caller-identity headers from_clientInfo, anyBearerToken, and the current contents of theHeadersdictionary (including theAuthorization/Content-Typeheaders set in step 2) onto the request, then — unlessPersistHeadersAfterRequestis set — clears theHeadersdictionary beforeclient.Execute(request)sends it. - 3.2. If the response status is one of the client's configured
RetryOnStatusCodes(the standard injectedIHttpJsonClient's defaultHttpClientOptionsretries on401 Unauthorized,502 Bad Gateway, and503 Service Unavailable) and retries remain (3 by default, for up to 4 total attempts), the client sleepsRetryDelayInMs(1000 ms by default) with a blockingThread.Sleep, then callsExecuteRestCallagain, which callsCreateRequesta second time. Because step 3.1 already cleared theHeadersdictionary on the first attempt, this rebuilt retry request carries only the identity headers andBearerToken(if set) — the customAuthorization/Content-Typeheaders added in step 2 are not present on any retried request. A consumer that callsSetOptionswith a differentHttpClientOptionsbeforeRefreshcould 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) and executed on behalf of this operation. - If
!result.IsSuccessful→ throwJwtRefreshException(result.StatusCode, result.ErrorException)(BR-OIDC-24). - On failure: exception propagates to the caller.
r = result.GetResult<RefreshTokenResult>(). Returnnew JwtSet { AccessToken = r.AccessToken, IdToken = r.IdToken, ExpiresAt = DateTime.UtcNow.AddSeconds(r.ExpiresIn) }(BR-OIDC-25).- 5.1.
GetResult<T>checksIsSuccessfulfirst (alwaystrueby the time step 5 runs, since step 4 already handled the failure case), then deserializesContentintoRefreshTokenResult. - On branch: the returned
refresh_tokenis 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
JwtRefreshExceptioncarrying the HTTP status code. The standard injectedIHttpJsonClientimplementation does retry automatically — up to 3 times, 1 second apart by default — when the response status is401,502, or503;JwtRefreshExceptionis only raised once retries are exhausted or the status is not one of the configured retryable codes. A caller-suppliedIHttpJsonClient/HttpClientOptionscould 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
Refreshtwice in sequence on the same scopedIHttpJsonClientinstance normally does not throw fromHeaders.Add, becauseCreateRequestclears custom headers immediately before the request is sent by default (PersistHeadersAfterRequest = false). A collision is possible if a consumer setsPersistHeadersAfterRequest = true, if two calls race concurrently on the same scoped instance before the first request'sCreateRequestclears its headers, or ifSetClientInfothrows (nullHttpContext, or an invalidOktaBaseUrl) afterSetBasicAuthenticationhas already added theAuthorizationheader but beforeCreateRequestever runs to clear it — leaving that header in place to collide with the next sequential call'sHeaders.Add*. - Retried requests (on
401/502/503, up to 3 times by default) do not carry the customAuthorization/Content-Typeheaders set in step 2, becauseCreateRequestclears theHeadersdictionary on the first attempt before any retry is triggered; only the identity headers andBearerToken(if set) are present on a retried request.
Related documentation¶
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.cssrc/SAIF.NetCore.HttpClients/SAIF.NetCore.HttpClients/SAIF.NetCore.HttpClients/HttpJsonClient/HttpJsonClient.cs(consumer dependency;SetBasicAuthentication,SetClientInfo,CreateRequest,ExecuteRestCallretry/header behavior)src/SAIF.NetCore.ServiceContracts/SAIF.NetCore.ServiceContracts/SAIF.NetCore.ServiceContracts/HttpClientOptions.cs(consumer dependency; defaultRetryOnStatusCodes/RetryDelayInMs/PersistHeadersAfterRequestvalues)