Skip to content

OIDC Client Primitives

Granit.Oidc is the foundation library for all OIDC and OAuth 2.0 interactions in the Granit framework. It provides strongly-typed records for every protocol message, a caching discovery document service, DPoP proof generation, PKCE helpers, and pluggable client authentication strategies. It does not perform HTTP calls to token endpoints itself — that responsibility belongs to higher-level packages like Granit.Bff and Granit.Oidc.TokenManagement, which reference this package and use its primitives to build, sign, and parse protocol messages.

If you are building a BFF, a token management layer, or any service that talks to an OIDC authorization server, this package gives you the building blocks without imposing an HTTP stack or a particular flow orchestration.

I need to…PackageWhy
Build or parse OIDC protocol messages (token requests, authorization URLs, discovery)Granit.OidcFoundation primitives — no HTTP calls, no middleware
Validate JWT Bearer tokens on an APIGranit.Authentication.JwtBearerMiddleware + claims transformation
Validate DPoP proofs on a resource serverGranit.Authentication.DPoPProof-of-possession middleware
Manage token lifecycle (acquire, cache, refresh) for outgoing HTTP callsGranit.Oidc.TokenManagementBuilt on top of this package
Run a full BFF with cookie sessions, YARP proxy, and silent refreshGranit.Bff / Granit.Bff.EndpointsBuilt on top of this package
Host your own OIDC serverGranit.OpenIddict.*OpenIddict-based authorization server

The following diagram helps you pick the right package for your scenario:

flowchart TD
    A[What are you building?] --> B{Resource server<br/>validating tokens?}
    B -->|Yes| C{DPoP required?}
    C -->|No| D["Granit.Authentication.JwtBearer<br/>+ provider module (Keycloak, EntraId...)"]
    C -->|Yes| E["Granit.Authentication.DPoP<br/>+ JwtBearer provider"]
    B -->|No| F{Calling an OIDC<br/>server yourself?}
    F -->|Yes| G{Full BFF with<br/>cookie sessions?}
    G -->|Yes| H["Granit.Bff.*<br/>(uses Oidc internally)"]
    G -->|No| I{Need automatic<br/>token caching/refresh?}
    I -->|Yes| J["Granit.Oidc.TokenManagement<br/>(uses Oidc internally)"]
    I -->|No| K["Granit.Oidc<br/>(protocol primitives only)"]
    F -->|No| L[You probably don't<br/>need this package]

    style K fill:#4a9eff,color:#fff,stroke-width:2px
    style H fill:#2ecc71,color:#fff
    style J fill:#2ecc71,color:#fff
  • DirectoryGranit.Oidc/
    • GranitOidcModule.cs Module registration
    • OidcConstants.cs Grant types, parameters, algorithms, discovery fields
    • DirectoryDiscovery/
      • IDiscoveryDocumentService.cs Fetch and cache .well-known/openid-configuration
      • OidcDiscoveryDocument.cs Immutable record with all endpoints
      • OidcDiscoveryException.cs Thrown on fetch/parse failure
    • DirectoryRequests/
      • TokenRequest.cs Abstract base + AuthorizationCodeTokenRequest, RefreshTokenRequest, ClientCredentialsTokenRequest
      • AuthorizationRequest.cs Builds authorization redirect URL via ToUrl()
      • EndSessionRequest.cs Builds logout redirect URL via ToUrl()
      • RevocationRequest.cs RFC 7009 token revocation
      • PushedAuthorizationRequest.cs RFC 9126 PAR
    • DirectoryResponses/
      • TokenResponse.cs Access token, refresh token, expiry, DPoP nonce, error
      • PushedAuthorizationResponse.cs Request URI + expiry or error
      • OidcError.cs Error code + description + URI
    • DirectoryClientAuthentication/
      • ClientAuthenticationMethod.cs Enum: ClientSecretPost, PrivateKeyJwt, ClientSecretBasic
      • IClientAuthenticationStrategy.cs Strategy pattern for token endpoint authentication
    • DirectoryDPoP/
      • IDPoPProofService.cs Generate EC P-256 key pairs and DPoP proof JWTs
    • DirectoryPkce/
      • PkceHelper.cs GenerateCodeVerifier() + ComputeCodeChallenge() (S256)

Register the module in your application’s module class. This registers IDiscoveryDocumentService and IDPoPProofService as singletons.

[DependsOn(typeof(GranitOidcModule))]
public class AppModule : GranitModule { }

The module depends only on GranitTimingModule (for IClock). All other types — request records, response records, PkceHelper, OidcConstants — are plain data classes and can be used without DI registration.

IDiscoveryDocumentService fetches and caches the .well-known/openid-configuration document for any OIDC authority. The cache is in-memory with a 24-hour TTL and uses per-authority semaphores to prevent thundering herd on cold start.

public sealed class AppointmentSyncService(
IDiscoveryDocumentService discovery)
{
public async Task<string> GetTokenEndpointAsync(CancellationToken ct)
{
OidcDiscoveryDocument doc = await discovery.GetAsync(
"https://idp.example.com",
ct).ConfigureAwait(false);
// All standard endpoints are available as typed properties
string tokenEndpoint = doc.TokenEndpoint;
string? parEndpoint = doc.PushedAuthorizationRequestEndpoint;
IReadOnlyList<string> dpopAlgs = doc.DPoPSigningAlgValuesSupported;
return tokenEndpoint;
}
}

The service validates that the issuer field in the response matches the requested authority (per OpenID Connect Discovery 1.0 section 4.3). A mismatch throws OidcDiscoveryException.

To force a refresh (e.g., after a key rotation at the identity provider), call InvalidateCache:

discovery.InvalidateCache("https://idp.example.com");
PropertyTypeRequiredSource field
IssuerstringYesissuer
AuthorizationEndpointstringYesauthorization_endpoint
TokenEndpointstringYestoken_endpoint
RevocationEndpointstring?Norevocation_endpoint
EndSessionEndpointstring?Noend_session_endpoint
UserInfoEndpointstring?Nouserinfo_endpoint
JwksUristring?Nojwks_uri
PushedAuthorizationRequestEndpointstring?Nopushed_authorization_request_endpoint
ScopesSupportedIReadOnlyList<string>Noscopes_supported
GrantTypesSupportedIReadOnlyList<string>Nogrant_types_supported
ResponseTypesSupportedIReadOnlyList<string>Noresponse_types_supported
DPoPSigningAlgValuesSupportedIReadOnlyList<string>Nodpop_signing_alg_values_supported

All token requests inherit from the abstract TokenRequest base record, which carries the ClientId and an AdditionalParameters dictionary for vendor-specific extensions.

After receiving an authorization code from the callback, exchange it for tokens:

var request = new AuthorizationCodeTokenRequest
{
ClientId = "patient-portal",
Code = authorizationCode,
RedirectUri = "https://app.example.com/callback",
CodeVerifier = storedCodeVerifier,
};

Silently acquire a new access token using a previously issued refresh token:

var request = new RefreshTokenRequest
{
ClientId = "patient-portal",
RefreshToken = currentRefreshToken,
Scope = "openid profile appointments:read",
};

Service-to-service communication where no user context is needed:

var request = new ClientCredentialsTokenRequest
{
ClientId = "invoice-processor",
Scope = "invoices:write patients:read",
};

TokenResponse represents the result of any token endpoint call. It uses a discriminated pattern: check IsSuccess before accessing token properties.

if (response.IsSuccess)
{
// Compiler knows AccessToken is non-null here (MemberNotNullWhen)
string accessToken = response.AccessToken;
string? refreshToken = response.RefreshToken;
int expiresIn = response.ExpiresIn;
string? tokenType = response.TokenType; // "Bearer" or "DPoP"
string? dpopNonce = response.DPoPNonce; // Server nonce for next request
}
else
{
// Compiler knows Error is non-null here
OidcError error = response.Error;
logger.LogWarning(
"Token request failed: {Error} — {Description}",
error.Error,
error.ErrorDescription);
}

When using DPoP, the authorization server may return a DPoP-Nonce header with both success and error responses. TokenResponse.DPoPNonce captures this value so callers can include it in subsequent DPoP proofs:

TokenResponse response = /* token endpoint call */;
if (response is { IsSuccess: false, Error.Error: "use_dpop_nonce" })
{
// Server requires a nonce — retry with the provided nonce
string nonce = response.DPoPNonce!;
// ... retry the request with IDPoPProofService.CreateProof(..., nonce)
}

For testing or error propagation, create error responses directly:

var error = TokenResponse.FromError(
"invalid_grant",
"The authorization code has expired.");

IDPoPProofService generates EC P-256 key pairs and creates signed DPoP proof JWTs per RFC 9449. This service is used by the BFF and TokenManagement packages to sender-constrain access tokens.

public sealed class InvoiceApiClient(IDPoPProofService dpop)
{
// Generate a key pair once (typically at client startup or per session)
private readonly string _privateKeyJwk = dpop.GenerateKeyPair();
public string CreateProofForTokenEndpoint(string tokenEndpoint, string? nonce)
{
return dpop.CreateProof(
_privateKeyJwk,
httpMethod: "POST",
httpUri: tokenEndpoint,
nonce: nonce);
}
public string CreateProofForResourceServer(string resourceUrl, string? nonce)
{
return dpop.CreateProof(
_privateKeyJwk,
httpMethod: "GET",
httpUri: resourceUrl,
nonce: nonce);
}
}

The generated proof JWT contains:

ClaimValuePurpose
typdpop+jwtIdentifies as a DPoP proof (RFC 9449 section 4.2)
algES256ECDSA P-256 + SHA-256
jwkPublic key (JWK)Binds the proof to the key pair
htmHTTP methodBinds to the request method
htuHTTP URI (scheme + authority + path)Binds to the target URL
iatCurrent timestampFreshness check
jtiRandom unique IDReplay protection
nonceServer-provided nonce (optional)Server-controlled replay window

PkceHelper implements RFC 7636 with the S256 challenge method. It generates cryptographically random code verifiers and computes their SHA-256 challenges.

// 1. Generate a code verifier (64 random bytes, base64url-encoded)
string codeVerifier = PkceHelper.GenerateCodeVerifier();
// 2. Compute the S256 challenge for the authorization request
string codeChallenge = PkceHelper.ComputeCodeChallenge(codeVerifier);
// 3. Include in the authorization request
var authRequest = new AuthorizationRequest
{
AuthorizationEndpoint = discoveryDoc.AuthorizationEndpoint,
ClientId = "patient-portal",
RedirectUri = "https://app.example.com/callback",
ResponseType = OidcConstants.ResponseTypes.Code,
Scope = "openid profile appointments:read",
State = GenerateState(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = OidcConstants.CodeChallengeMethods.S256,
};
// 4. Redirect the user
string redirectUrl = authRequest.ToUrl();
// 5. After callback, include the verifier in the token request
var tokenRequest = new AuthorizationCodeTokenRequest
{
ClientId = "patient-portal",
Code = receivedCode,
RedirectUri = "https://app.example.com/callback",
CodeVerifier = codeVerifier, // Proves possession of the challenge
};

The package provides three client authentication methods via the strategy pattern. Each implementation of IClientAuthenticationStrategy knows how to add the appropriate parameters to a token endpoint request.

The client secret is included as a POST body parameter. This is the simplest method and the default for most OIDC providers.

// The strategy adds client_secret to the form parameters
// parameters["client_secret"] = "my-secret-value"
ParameterValue
client_idIncluded by the request record
client_secretAdded by the strategy

AuthorizationRequest builds a complete authorization redirect URL. Use it to start the authorization code flow with PKCE:

var request = new AuthorizationRequest
{
AuthorizationEndpoint = discoveryDoc.AuthorizationEndpoint,
ClientId = "patient-portal",
RedirectUri = "https://app.example.com/callback",
ResponseType = OidcConstants.ResponseTypes.Code,
Scope = "openid profile email appointments:read",
State = state,
CodeChallenge = PkceHelper.ComputeCodeChallenge(codeVerifier),
CodeChallengeMethod = OidcConstants.CodeChallengeMethods.S256,
Nonce = nonce,
};
string redirectUrl = request.ToUrl();
// https://idp.example.com/connect/authorize?client_id=patient-portal&redirect_uri=...

RFC 9126 allows clients to push authorization parameters directly to the server, receiving a short-lived request_uri in return. This keeps sensitive parameters (scope, redirect URI) off the browser’s URL bar and prevents parameter tampering.

// 1. Build the PAR request
var parRequest = new PushedAuthorizationRequest
{
ClientId = "patient-portal",
RedirectUri = "https://app.example.com/callback",
ResponseType = OidcConstants.ResponseTypes.Code,
Scope = "openid profile appointments:read",
State = state,
CodeChallenge = codeChallenge,
CodeChallengeMethod = OidcConstants.CodeChallengeMethods.S256,
};
// 2. POST to the PAR endpoint (your HTTP layer handles this)
PushedAuthorizationResponse parResponse = /* POST to par_endpoint */;
// 3. Check for success
if (parResponse.IsSuccess)
{
// 4. Redirect using the request_uri instead of inline parameters
string redirectUrl = $"{discoveryDoc.AuthorizationEndpoint}" +
$"?client_id={parRequest.ClientId}" +
$"&request_uri={Uri.EscapeDataString(parResponse.RequestUri!)}";
}
else
{
logger.LogError("PAR failed: {Error}", parResponse.Error!.Error);
}

RevocationRequest implements RFC 7009 for revoking access tokens and refresh tokens:

// Revoke a refresh token (cascade revokes the access token in most IdPs)
var revocationRequest = new RevocationRequest
{
ClientId = "patient-portal",
Token = refreshToken,
TokenTypeHint = OidcConstants.TokenTypes.RefreshToken,
};
// Revoke an access token explicitly (defense-in-depth)
var accessRevocation = new RevocationRequest
{
ClientId = "patient-portal",
Token = accessToken,
TokenTypeHint = OidcConstants.TokenTypes.AccessToken,
};

EndSessionRequest builds an RP-Initiated Logout URL per OpenID Connect RP-Initiated Logout 1.0:

var logoutRequest = new EndSessionRequest
{
EndSessionEndpoint = discoveryDoc.EndSessionEndpoint!,
IdTokenHint = idToken,
PostLogoutRedirectUri = "https://app.example.com/signed-out",
ClientId = "patient-portal",
State = logoutState,
};
string logoutUrl = logoutRequest.ToUrl();
// https://idp.example.com/connect/endsession?id_token_hint=...&post_logout_redirect_uri=...

OidcConstants provides compile-time constants for all standard OAuth 2.0 and OIDC protocol values, organized by category:

Nested classExamplesSpec
GrantTypesAuthorizationCode, RefreshToken, ClientCredentials, TokenExchange, DeviceCodeRFC 6749, 8693, 8628
ResponseTypesCodeRFC 6749
CodeChallengeMethodsS256RFC 7636
TokenTypesAccessToken, RefreshTokenRFC 7009
TokenTypeIdentifiersURN-based identifiers for token exchangeRFC 8693
ClientAssertionTypesJwtBearerRFC 7523
ParametersClientId, GrantType, Code, RedirectUri, CodeVerifier, State, Nonce, …RFC 6749, 7636
DiscoveryIssuer, AuthorizationEndpoint, TokenEndpoint, JwksUri, PushedAuthorizationRequestEndpoint, …RFC 8414
AlgorithmsES256, PS256RFC 7518

Use these constants instead of string literals to catch typos at compile time:

// Correct
parameters[OidcConstants.Parameters.GrantType] = OidcConstants.GrantTypes.AuthorizationCode;
// Avoid — typos become runtime bugs
parameters["grant_type"] = "authorization_code";
CategoryKey typesDescription
ModuleGranitOidcModuleRegisters IDPoPProofService + IDiscoveryDocumentService
DiscoveryIDiscoveryDocumentService, OidcDiscoveryDocument, OidcDiscoveryExceptionFetch, cache, and validate .well-known/openid-configuration
Token requestsTokenRequest (abstract), AuthorizationCodeTokenRequest, RefreshTokenRequest, ClientCredentialsTokenRequestTyped records for each grant type
URL buildersAuthorizationRequest, EndSessionRequestBuild redirect URLs via ToUrl()
ResponsesTokenResponse, PushedAuthorizationResponse, OidcErrorParse and inspect endpoint responses
RevocationRevocationRequestRFC 7009 token revocation
PARPushedAuthorizationRequest, PushedAuthorizationResponseRFC 9126 Pushed Authorization Requests
Client authClientAuthenticationMethod, IClientAuthenticationStrategyStrategy pattern for client_secret_post, private_key_jwt, client_secret_basic
DPoPIDPoPProofServiceEC P-256 key generation and DPoP proof JWT creation (RFC 9449)
PKCEPkceHelperGenerateCodeVerifier() + ComputeCodeChallenge() (RFC 7636)
ConstantsOidcConstantsAll standard parameter names, grant types, algorithms, discovery fields