Articles · Operations & Administration

An OIDC server inside your app

Try out the running example

Your product grew an ecosystem: a partner wants “Sign in with YourApp”, the mobile team wants standard OAuth, the data team wants service-to-service tokens. The usual outcomes are a Duende invoice or a Keycloak deployment. Sentinel’s answer: mount the authorization server inside the app you already run.

Mount it

Starting from the first-login host:

builder.Services.AddSentinelOidcServer(o =>
{
    o.Issuer = "https://id.example.com";
    // LoginPath = "/login", ConsentPath = "/consent" — your pages, see below
});

app.MapSentinelOidc();

Check the front door:

GET /.well-known/openid-configuration

The document advertises exactly what’s implemented — response_types ["code"], grants authorization_code | refresh_token | client_credentials, PKCE S256 only, RS256, back-channel logout — and nothing more. No implicit, no ROPC, no plain-PKCE: the dead ends aren’t configurable back on.

Register a client

Clients enter through declarative config (or the admin surface — never anonymous registration):

oidcClients:
  - clientId: partner-portal
    clientType: confidential
    secretRef: PARTNER_PORTAL_SECRET        # env var name; the file holds no secrets
    redirectUris:
      - "https://portal.partner.example/signin-oidc"
    allowedScopes: [openid, profile, email, offline_access]
    requireConsent: true                     # third party → ask the user
    backChannelLogoutUri: "https://portal.partner.example/bc-logout"

Redirect URIs match by exact ordinal comparison — no wildcard subdomains, no “starts with” bugs. Secrets are hashed at rest and rotate with an overlap window, so a deploy can roll credentials without a coordinated flag day.

The interaction contract: your UI stays yours

Sentinel ships no hosted login page — the authorize endpoint delegates interaction to your app and resumes when you’re done:

  1. GET /oidc/authorize?client_id=partner-portal&response_type=code&code_challenge=… validates the request, then redirects to your LoginPath with the pending request reference when no session exists.
  2. Your page authenticates however you like — password, passkey, federated — using the normal /auth/* endpoints and your own markup.
  3. If consent is required, Sentinel redirects to your ConsentPath; your page renders the scopes and posts the decision to POST /oidc/consent. Granted consent persists (revocably) so returning users skip the prompt; FirstParty clients skip it always.
  4. The flow resumes and the browser lands back on the client’s redirect URI with a code.

The token exchange

POST /oidc/token
Authorization: Basic cGFydG5lci1wb3J0YWw6…
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=…&code_verifier=…&redirect_uri=…
{
  "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 600,
  "scope": "openid profile offline_access",
  "refresh_token": "srt_…", "id_token": "eyJ…"
}

Two behaviors here are worth your attention:

  • Codes are single-use with consequences. A code lives two minutes; consuming it twice revokes the refresh family the first consumption minted and emits oidc.code_reuse (pinned by Authorization_code_is_single_use_and_reuse_emits_the_event). Code replay is treated as the compromise indicator it is — same philosophy as refresh-token families, which OIDC refresh tokens reuse wholesale.
  • Refresh can only narrow. The persisted grant remembers its scopes; a refresh request may drop scopes but never add them.

client_credentials rides the same endpoint and maps onto Sentinel service accounts — machine tokens with no refresh and no ID token, honoring an audience parameter.

Resource-server plumbing

Opaque needs? POST /oidc/introspect (RFC 7662) answers active, scopes, subject and expiry for resource servers that don’t verify JWTs locally. POST /oidc/revoke (RFC 7009) kills refresh tokens client-side. And GET /oidc/userinfo serves the standard claims for the scopes granted.

Logout is both-directions: GET /oidc/logout (RP-initiated, with post-logout redirect validation) ends the Sentinel session, and back-channel logout pushes signed logout tokens to every client that holds a live grant for that session and declares a backChannelLogoutUri — delivery failures emit oidc.backchannel_logout_failed instead of vanishing.

Keys without ceremony

The JWKS at /oidc/jwks is the same signing key ring first-party tokens use: one primary, rotation with a 7-day overlap, retired keys verifying until the window closes. Rotate whenever — clients following the JWKS never notice. And because production keys must come from the persisted store, “the IdP restarted and every session died” is a failure mode Sentinel refuses to have.

What to require of yourself

Running an AS is a protocol responsibility. Sentinel carries the spec surface and runs an internal conformance suite in CI (official OpenID Foundation conformance + certification is the pre-1.0 commitment), but you own: TLS termination and the issuer URL’s stability, the quality of your login/consent pages (they are the phishing surface now), and client hygiene — exact redirect URIs, confidential where possible, requireConsent for anyone you don’t own. The OIDC provider reference has the full endpoint and option tables.