Skip to content

Use case — Protect an API (resource server) ​

The op package issues tokens. The op/rs package checks them on the other side: it turns an incoming request into a verified token, or into an error carrying the HTTP status and the WWW-Authenticate challenge the specifications prescribe. Use it when you write the API yourself and want the OP's tokens to guard it, in the same process or in another one.

If the API only needs to know whether a token is valid and you already run a gateway that verifies JWTs, op/rs adds little. It earns its place when you want sender-constraint checks (DPoP, mTLS), scope and step-up requirements, and correct challenges without assembling them from the RFCs.

Specs referenced on this page

Source: examples/36-resource-server runs an OP and an /api/orders route guarded by op/rs, calls it as a machine client, and prints the success and three refusals.

Build the verifier ​

go
import "github.com/libraz/go-oidc-provider/op/rs"

verifier, err := rs.New(rs.Config{
  Issuer:   "https://op.example.com",
  Audience: "https://api.example.com",
})

Issuer and Audience are required. rs.New does no network I/O; the OP's discovery document and JWK Set are fetched on first use and cached. Build one *rs.Verifier at startup and share it, since it is safe for concurrent use.

FieldMeaning
IssuerThe iss of every accepted token must equal it byte for byte. Discovery at Issuer + "/.well-known/openid-configuration" must name the same issuer.
AudienceThis API's identifier. A token is accepted only when its aud (a string or an array) contains it. It is also the realm of the Bearer challenge.
JWKSURIThe JWK Set URL. Empty takes jwks_uri from discovery.
IntrospectionEnables opaque tokens; see below.
DPoPrs.DPoPAllowed (zero value) or rs.DPoPRequired.
DPoPReplayA store.ConsumedJTIStore for used DPoP proofs. Nil keeps them in process.
MTLSProxyThe *op.MTLSProxy naming a trusted proxy and its certificate header, the value op.WithMTLSProxy records and (*op.Provider).MTLSProxy() reads back.
HTTPClient, Logger, ClockTransport and timeout for outbound fetches, a logger for their failures, and a time source.

Every outbound fetch (discovery, JWK Set, introspection) goes through the library's SSRF-hardened client: https is required, redirects are never followed, responses are size-capped, and private addresses are refused. AllowPrivateNetwork admits private addresses, and plain http to a loopback IP literal, for local development only. Example 36 sets it because its issuer is http://127.0.0.1:8080.

Guard routes ​

Middleware wraps a handler with the verification plus the requirements the route needs:

go
requireOrdersRead := verifier.Middleware(rs.RequireScope("orders:read"))
mux.Handle("/api/orders", requireOrdersRead(http.HandlerFunc(listOrders)))

A request that passes reaches the handler with the token in its context. A request that fails gets the error's status, its challenge and an empty body, and the handler does not run.

Read the token inside the handler with rs.FromContext:

go
func listOrders(w http.ResponseWriter, r *http.Request) {
  tok, ok := rs.FromContext(r.Context())
  if !ok {
    http.Error(w, "no verified token", http.StatusInternalServerError)
    return
  }
  // tok.Subject, tok.ClientID, tok.Scopes, ...
}

rs.Token carries Subject, ClientID, Scopes, ACR, AMR, AuthTime, ExpiresAt, the sender constraint in Binding (JKT or CertThumbprint, both empty for a bearer token), and every claim of the payload in Claims. For a client_credentials token the subject is the client's own identifier.

Code that does not sit behind net/http calls verifier.Verify(r) directly. It returns the *rs.Token, or an error that is always an *rs.Error with Status, Code, Description and WWWAuthenticate().

The three requirements are a closed set:

RequirementFails with
rs.RequireScope("orders:read", ...)403 insufficient_scope, the required scopes in the scope attribute
rs.RequireACR(levels, acr)401 insufficient_user_authentication, acr_values in the challenge
rs.RequireMaxAge(d)401 insufficient_user_authentication, max_age in whole seconds

RequireACR takes the same op.ACRLevels value the OP was configured with, so both sides rank acr values identically, and it panics at wiring time if acr is not declared in levels. RequireMaxAge needs a positive duration, and a token without auth_time does not meet it. A nil requirement panics in Middleware instead of leaving the route unguarded. The OP's half of this step-up is described in MFA / step-up.

What is checked ​

A JWT access token is verified locally against the issuer's JWK Set (RFC 9068 §4):

  • the typ header is at+jwt or application/at+jwt
  • the signature, with an alg of RS256, PS256, ES256 or EdDSA, and equal to the JWK's alg when the key declares one; none and the HMAC family are never accepted
  • the required claims, iss, aud, exp and iat, with no leeway on either time

A kid the cached set does not hold triggers one throttled refetch, which is how a key rotation propagates to the API. A JWT that fails verification is not retried through introspection.

Opaque tokens ​

Tokens that are not JWTs go to RFC 7662 introspection when Config.Introspection is set, and are rejected as invalid_token otherwise. The API authenticates to the OP as a client:

go
verifier, err := rs.New(rs.Config{
  Issuer:   "https://op.example.com",
  Audience: "https://api.example.com",
  Introspection: &rs.IntrospectionConfig{
    ClientID:     "orders-api",
    ClientSecret: secret,
  },
})

Endpoint is optional and defaults to introspection_endpoint from discovery. Choose exactly one way for the API to authenticate; rs.New fails for none or for more than one:

SetMethodNotes
ClientSecretclient_secret_basic
PrivateKey (a crypto.Signer) and KeyIDprivate_key_jwtthe assertion's aud is the issuer; ES256 for a P-256 key, PS256 for RSA, EdDSA for Ed25519
TLSCertificatetls_client_auth or self_signed_tls_client_authwith an empty Endpoint, the mtls_endpoint_aliases introspection endpoint from discovery is used when the OP publishes one

The verifier asks for an RFC 9701 JWT introspection response and verifies it (typ of token-introspection+jwt, the issuer as iss, the client's ID in aud, signature against the OP's JWK Set). It still accepts a plain JSON response from an OP that sends one. On the OP side, three things have to line up:

go
op.WithFeature(feature.Introspect),
op.WithAccessTokenFormat(store.AccessTokenFormatOpaque), // when the tokens are opaque
op.WithProtectedResources(op.ProtectedResource{
  Resource:             "https://api.example.com",
  IntrospectionClients: []string{"orders-api"},
}),

orders-api must also be a registered confidential client. The OP answers an introspection request with the token's contents only when the authenticating client is listed in the IntrospectionClients of a resource the token is addressed to. Otherwise every token reads as inactive, and the API rejects every call. The delegation is scoped to that resource: tokens for another resource stay invisible to the client, and refresh tokens are never covered. An opaque token store has to be present on the OP side (Store.OpaqueAccessTokens is required when the format is opaque).

The default access token format is JWT. An inactive answer is invalid_token. A transport or protocol failure is the server's own fault and surfaces as 503.

rs does not cache introspection results, so each call to the API makes one request to the OP. Put a cache in front if that cost matters, or issue JWTs.

Bind tokens to a sender ​

A token whose cnf carries jkt is bound to a DPoP key (RFC 9449). It must arrive under the DPoP scheme with a valid proof for the same key: the proof's htu and htm must match the request, its ath must match the token, and its jti is consumed once. A token whose cnf carries x5t#S256 is certificate-bound (RFC 8705 §3), and the connection must present the bound certificate. The cnf of an introspection response is enforced the same way as the cnf of a JWT.

  • DPoPAllowed accepts bearer tokens, and accepts a DPoP-bound token only under the DPoP scheme. The challenge offers both schemes.
  • DPoPRequired accepts only DPoP-bound tokens under the DPoP scheme. The challenge offers DPoP alone.
  • A bound token presented as a plain bearer is invalid_token. A bad proof is invalid_dpop_proof, and a replayed proof is rejected too.

The verifier checks the proof's htu against the URL the request was addressed to, built from r.URL, r.Host and r.TLS. Behind a reverse proxy that rewrites the host or terminates TLS, restore those fields before the request reaches the verifier.

By default DPoPReplay is an in-process store, so a replay is detected only on the instance that saw the first use. With more than one instance, supply a shared store such as the one your OP uses; rs.New logs one warning for the in-process default when Logger is set. The verifier does not issue DPoP nonces. For certificate binding behind a proxy that terminates client TLS, set MTLSProxy.

With a FAPI 2.0 OP ​

Against an OP that runs profile.FAPI2Baseline or profile.FAPI2MessageSigning:

  • Prefer JWT access tokens. rs verifies them locally, with no call to the OP and no client credential to manage.
  • For opaque tokens, authenticate with a key or a certificate. The FAPI profiles refuse client_secret_basic, so set IntrospectionConfig.PrivateKey with KeyID (the key's public half registered as the API client's JWKS), or TLSCertificate for an mTLS client. An introspection client with a shared secret is rejected by the OP.
  • Expect a signed answer from Message Signing. profile.FAPI2MessageSigning makes /introspect respond with an RFC 9701 JWT whatever the request asks for, and rs verifies it against the OP's JWK Set without extra configuration.
  • Check the sender constraint. A DPoP-bound or certificate-bound token is enforced as described in Bind tokens to a sender.

The OP side of these profiles is described in FAPI 2.0 Baseline and FAPI 2.0 Message Signing.

Challenges ​

On failure the middleware writes the status and a WWW-Authenticate header, and no body:

SituationStatuserror
No Authorization header401none: a bare challenge with the realm
More than one header, unknown scheme, malformed token400invalid_request
Wrong issuer or audience, expired, bad signature, inactive, binding mismatch401invalid_token
Bad or replayed DPoP proof401invalid_dpop_proof
Missing scope403insufficient_scope
acr or max_age not met401insufficient_user_authentication
Discovery, JWK Set, introspection or the replay store unreachable503none; no challenge is written

Under DPoPAllowed the header holds two challenges, Bearer realm="https://api.example.com", ... and DPoP algs="...". The error attributes ride only on the challenge of the scheme the client used, and a request without credentials gets neither. error_description is a fixed string that never echoes request data. The scope, acr_values and max_age attributes carry what the route required, which is what a client needs to start a step-up (RFC 9470 §3).

Choose the audience ​

The verifier accepts a token only when Config.Audience is among its aud values, so the OP has to address tokens to the API. A client asks for that with the RFC 8707 resource parameter on the token request:

sh
curl -u orders-client:... \
  -d 'grant_type=client_credentials&scope=orders:read' \
  --data-urlencode 'resource=https://api.example.com' \
  https://op.example.com/oidc/token

Three settings make it work:

  • op.WithProtectedResources(op.ProtectedResource{Resource: "https://api.example.com", ...}) declares the API. The OP publishes it as protected-resource metadata and issues tokens for it.
  • The client's Resources list names the resources it may request. A client with an empty list has its resource requests rejected.
  • Config.Audience is that same identifier.

Without resource, the OP addresses the token to itself, with the issuer as aud, and the API refuses it. Example 36 shows all three refusals: no token, a token without the scope, and a token for another audience. See Protected resource metadata for what the OP publishes about the API.

Calling the API and /userinfo from the same SPA ​

A SPA that wants to call your API and read the user's profile hits an audience rule. /userinfo accepts an access token only when the token's aud contains the OP's issuer. A token requested with resource=https://api.example.com is addressed to the API alone, so the OP answers /userinfo with 401 invalid_token, and the same token is exactly what the API's verifier wants. A token requested without resource has the issuer as aud and works at /userinfo, but the API refuses it.

Nothing converts one into the other. A refresh token is bound to the resource of the grant that created it, so a refresh_token request cannot change the audience of the access token it returns. The OP does not relax the /userinfo check either: accepting tokens addressed to a resource server would let any API holding such a token read the user's profile, and the API's own audience check is the only thing that stops it.

The recommended shape is to read the profile from the ID Token, which the token endpoint returns alongside the access token. Name the scopes whose claims should travel in it:

go
op.New(
  /* required options */
  op.WithProtectedResources(op.ProtectedResource{Resource: "https://api.example.com"}),
  op.WithIDTokenScopeClaims("profile", "email"),
)

With that option, the ID Token of the authorization_code, refresh_token, device_code and CIBA grants carries the claims that profile and email release at /userinfo, whenever the response's scope includes them. The SPA gets the profile and the API-bound access token from one token response. The option departs from OIDC Core 1.0 §5.4 on purpose, and it enlarges the ID Token; see Scopes. Without it, the other route is a second authorization request without resource, whose access token is for /userinfo only.

Where the API sits ​

The API can share a process and a router with the OP, or live apart. When they share a router, mount the OP route by route so the application's own 404s and API routes stay yours; example 36 does exactly that.

Next ​