Skip to content

luikyv/go-oidc

Repository files navigation

go-oidc

Go Reference Go Report Card License

A configurable OpenID Connect Provider for Go.

Supported Specifications

Drafts

Certification

Luiky Vasconcelos has certified that go-oidc conforms to the following profiles of the OpenID Connect™ protocol.

  • Basic OP, Implicit OP, Hybrid OP, Config OP and Dynamic OP
  • FAPI 1.0
  • FAPI 2.0

OpenID Certification

Get Started

Install the module:

go get github.com/luikyv/go-oidc@latest

Create and run a provider:

key, _ := rsa.GenerateKey(rand.Reader, 2048)
jwks := goidc.JSONWebKeySet{
  Keys: []goidc.JSONWebKey{{
    KeyID:     "key_id",
    Key:       key,
    Algorithm: "RS256",
  }},
}

op, _ := provider.New(
  "http://localhost",
  nil,
  func(_ context.Context) (goidc.JSONWebKeySet, error) {
    return jwks, nil
  },
)
op.Run(":80")

Verify the setup at http://localhost/.well-known/openid-configuration.

Table of Contents

Running the Provider

The simplest way to run the provider:

op.Run(":80")

For more flexibility, use op.Handler() to get an http.Handler with all endpoints configured:

mux := http.NewServeMux()
mux.Handle("/", op.Handler())

server := &http.Server{
  Addr:    ":443",
  Handler: mux,
}
server.ListenAndServeTLS(certFilePath, certKeyFilePath)

Grants

goidc.Grant represents what was authorized after the user grants access, or after the client is authorized directly in non-user flows such as client_credentials.

It is the canonical record of the authorization state. A grant may contain:

  • the subject and optional username
  • the client ID
  • granted scopes
  • authorization details
  • resource indicators
  • proof-of-possession bindings such as DPoP or mTLS thumbprints
  • flow-specific identifiers such as authorization code, refresh token, device code, or auth_req_id

A grant is created after the authorization step succeeds. For example:

  • in the authorization code flow, it is created when the user completes authentication and consent
  • in CIBA or device flows, it is created when the pending interaction is approved
  • in client_credentials, it is created directly from the validated token request

goidc.Token is derived from a grant. Tokens can narrow scopes, resources, or authorization details, but they always originate from one grant through Token.GrantID. For JWT access tokens, the grant_id claim links the token back to its grant.

This means one grant can produce multiple tokens over time, especially when:

  • refresh tokens are enabled
  • the client narrows scopes or resources on subsequent token requests
  • the same authorization is redeemed more than once through allowed flows

In practice, goidc.Grant is the long-lived authorization state, while goidc.Token is one issued credential under that state.

Tokens

goidc.Token represents one issued access token.

Access tokens are JWTs by default. JWT access tokens are self-contained and are not persisted server-side. The token claims include a grant_id that links back to the originating goidc.Grant, allowing introspection and revocation to resolve the grant without storing individual token records.

If you need opaque access tokens, enable them with provider.WithOpaqueTokens(manager). When opaque tokens are enabled, go-oidc stores a goidc.Token record for each issued opaque token through the configured goidc.OpaqueTokenManager.

The access token format and lifetime are controlled by provider.WithTokenOptions(...). This option receives the current goidc.Grant and goidc.Client and returns a goidc.TokenOptions value for that issuance.

For example, to issue opaque access tokens:

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithOpaqueTokens(manager),
  provider.WithTokenOptions(func(_ context.Context, _ *goidc.Grant, _ *goidc.Client) goidc.TokenOptions {
    return goidc.NewOpaqueTokenOptions(600)
  }),
)

Use goidc.NewJWTTokenOptions(...) for JWT access tokens or goidc.NewOpaqueTokenOptions(...) for opaque access tokens. Opaque tokens require provider.WithOpaqueTokens(...) to be enabled.

Additional access token claims can be added with provider.WithTokenClaims(...). The function receives the issued goidc.Token and its source goidc.Grant.

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithTokenClaims(func(_ context.Context, token *goidc.Token, grant *goidc.Grant) map[string]any {
    return map[string]any{
      "roles":  grant.Store["roles"],
      "tenant": grant.Store["tenant"],
    }
  }),
)

goidc.Token is created from a goidc.Grant and captures the exact authorization state attached to that token at issuance time. A token may therefore contain:

  • its own ID
  • the associated GrantID
  • the subject and client ID
  • the active scopes for that token
  • authorization details
  • resource indicators
  • proof-of-possession bindings such as DPoP or mTLS thumbprints
  • issuance and expiration timestamps
  • token format and token type

A token is not the same thing as a grant. The grant is the durable record of what was authorized; the token is one credential issued under that record. Because of that, the token may hold a subset of the grant data. For example, a refresh token request can issue a new access token with narrower scopes or resources while keeping the same underlying grant.

Each token has its own lifetime. When a new token is issued from the same grant, the previous token is not implicitly the same logical credential; it is a distinct goidc.Token with its own ID, timestamps, and confirmation data.

For JWT access tokens, the token value is self-contained and not stored server-side. For opaque access tokens, the goidc.Token record is persisted through the configured goidc.OpaqueTokenManager.

Authorization Code and Implicit Grants

The authorization code, implicit, and hybrid flows are enabled through provider.WithAuthCodeGrant(...).

This option does two things:

  • enables the authorization_code grant
  • registers the response types accepted at the authorization endpoint

The response types you pass determine which flows are available:

  • goidc.ResponseTypeCode enables the authorization code flow
  • goidc.ResponseTypeToken or goidc.ResponseTypeIDToken enable implicit flows
  • combined response types such as goidc.ResponseTypeCodeAndIDToken enable hybrid flows

Example with only authorization code:

manager := storage.NewManager(1000)

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithAuthCodeGrant(manager, goidc.ResponseTypeCode),
)

Example with authorization code, implicit, and hybrid response types:

manager := storage.NewManager(1000)

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithAuthCodeGrant(
    manager,
    goidc.ResponseTypeCode,
    goidc.ResponseTypeToken,
    goidc.ResponseTypeIDToken,
    goidc.ResponseTypeCodeAndToken,
    goidc.ResponseTypeCodeAndIDToken,
  ),
)

If you also want refresh tokens, enable them separately with provider.WithRefreshTokenGrant(...).

Refresh Token Grant

The refresh token grant is enabled with provider.WithRefreshTokenGrant(...).

manager := storage.NewManager(1000)

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithAuthCodeGrant(manager, goidc.ResponseTypeCode),
  provider.WithRefreshTokenGrant(manager),
)

When this grant is enabled, goidc.Grant may carry a refresh token. Later, when the client calls the token endpoint with grant_type=refresh_token, the provider loads that existing grant, validates the request, and issues a new goidc.Token under the same grant.

This means the refresh token grant does not create a new authorization. It reuses an existing one.

By default, the same refresh token remains associated with the grant. To rotate refresh tokens on each use, add provider.WithRefreshTokenRotation().

You can also customize the refresh token lifetime with provider.WithRefreshTokenLifetime(...).

If provider.WithRefreshTokenLifetime(...) is not configured, refresh tokens do not expire.

Passing provider.WithRefreshTokenLifetime(0) also makes refresh tokens non-expiring. In that case, goidc.Grant.RefreshTokenExpiresAt remains 0.

When refresh token rotation is enabled, the rotated refresh token follows the same lifetime rule. If the configured lifetime is 0, the new refresh token also does not expire.

Client Credentials Grant

The client credentials grant is enabled with provider.WithClientCredentialsGrant().

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithClientCredentialsGrant(),
)

This flow does not involve an end-user, an authentication session, or consent screen. The client authenticates directly at the token endpoint and, if the request is valid, the provider creates a goidc.Grant and issues a goidc.Token for the client itself.

In this case, the grant represents what the client was authorized to access, not what a user delegated. The resulting token is therefore tied to the client rather than to a user authentication event.

The JWT bearer grant is enabled with provider.WithJWTBearerGrant(...).

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithJWTBearerGrant(func(ctx context.Context, assertion string) (goidc.JWTBearerResult, error) {
    return goidc.JWTBearerResult{Subject: "subject"}, nil
  }),
)

This enables the urn:ietf:params:oauth:grant-type:jwt-bearer grant type. When a token request uses that grant, go-oidc delegates assertion handling to the function passed to WithJWTBearerGrant(...).

That function receives the raw assertion and must validate it according to your deployment rules. It returns a goidc.JWTBearerResult containing the subject represented by the assertion (and optionally a Store map for extra data), or an error if the assertion is invalid.

If the assertion is accepted, the provider creates a goidc.Grant for that subject and issues a goidc.Token from it. This makes the JWT bearer grant a direct token flow, similar to client_credentials, but driven by an external assertion instead of a client-only authorization.

Use provider.WithJWTBearerGrantClientAuthnRequired() if the client must also authenticate in addition to presenting the bearer assertion.

CIBA is enabled with provider.WithCIBAGrant(...).

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithCIBAGrant(
    manager,
    goidc.CIBADeliveryModePoll,
    goidc.CIBADeliveryModePing,
    goidc.CIBADeliveryModePush,
  ),
)

In this flow, the client starts authentication through the backchannel authentication endpoint and receives an auth_req_id. At that point, there is still no goidc.Grant. The provider stores a pending goidc.AuthnSession associated with that auth_req_id.

Later, when the user approves or denies the request, the provider resolves that pending session:

  • if access is granted, the session becomes a goidc.Grant
  • if access is denied, the provider notifies the client according to the configured delivery mode
  • if the client polls the token endpoint before completion, the request returns authorization_pending

Once approved, the client exchanges the auth_req_id at the token endpoint using the CIBA grant type and receives tokens derived from the newly created grant.

The delivery modes available to clients are configured directly on provider.WithCIBAGrant(...).

The device code grant is enabled with provider.WithDeviceGrant(...).

manager := storage.NewManager(1000)

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithDeviceGrant(
    manager,
    promptUserCodePage,
    confirmationPage,
  ),
)

This flow starts at the device authorization endpoint. After the client is validated, the provider creates a pending goidc.AuthnSession containing a device_code and a user_code.

The user then visits the device verification endpoint and enters the user_code. From there, the configured authentication policy runs against that pending session:

  • if authentication succeeds, the session becomes a goidc.Grant
  • if authentication is still pending, the session is persisted and can be resumed
  • if authentication fails, the session is marked as failed and the request is denied

Later, the client exchanges the device_code at the token endpoint using the device code grant type and receives tokens derived from the resulting grant.

WithDeviceGrant(...) also requires two render functions:

  • one to prompt the user for the user_code
  • one to render the confirmation page after successful authorization

Token exchange is enabled with provider.WithTokenExchangeGrant(...).

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithTokenExchangeGrant(
    func(ctx context.Context, req goidc.TokenExchangeRequest) (goidc.TokenExchangeResult, error) {
      // Validate the subject token and determine the subject.
      sub, err := validateSubjectToken(req.SubjectToken, req.SubjectTokenType)
      if err != nil {
        return goidc.TokenExchangeResult{}, err
      }
      return goidc.TokenExchangeResult{Subject: sub}, nil
    },
  ),
)

This enables the urn:ietf:params:oauth:grant-type:token-exchange grant type. When a token request uses that grant, go-oidc delegates token validation to the function passed to WithTokenExchangeGrant(...).

That function receives the full exchange request — subject token, actor token, requested token type, audience, and resource — and must validate the tokens according to your deployment rules. It returns the subject for the resulting grant, and optionally a Store map for custom data (e.g., an act claim for delegation scenarios).

The provider then creates a goidc.Grant for that subject and issues a token based on the requested_token_type. When no type is requested, it defaults to an access token. Supported types are access tokens, ID tokens, and refresh tokens.

PAR is enabled with provider.WithPAR(manager) and can be made mandatory with provider.WithPARRequired(manager).

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithAuthCodeGrant(manager, goidc.ResponseTypeCode),
  provider.WithPAR(manager),
)

When the client calls the PAR endpoint, the provider validates the pushed request and stores it as a short-lived goidc.AuthnSession. The response contains a request_uri that identifies that stored session.

Later, the client calls the authorization endpoint with that request_uri. At that point, go-oidc loads the stored session and continues the authorization flow from it.

In practice, PAR changes where the authorization parameters are validated and persisted:

  • the initial validation happens at the PAR endpoint
  • the resulting request_uri points to the stored authorization session
  • the later authorization request reuses that stored state instead of starting from scratch

If JAR is also enabled, the pushed request may carry the request object and the stored session will reflect the validated JAR content.

The PAR endpoint lifetime can be customized with provider.WithPARLifetime(...).

Authentication Policies

Authorization requests (starting at /authorize by default) are handled by goidc.AuthnPolicy.

Each policy has two parts:

  1. Setup: decides whether the policy applies to the current request and session.
  2. Authenticate: performs the user interaction and authentication work.

When a request reaches the authorization endpoint, go-oidc creates or loads a goidc.AuthnSession, selects the first policy whose Setup function returns true, and then calls Authenticate.

The authentication function returns one of:

  • goidc.StatusSuccess: authentication succeeded. The session must contain the data needed to create the grant, especially Subject.
  • goidc.StatusPending: the flow is suspended and the session is persisted. The user agent can then continue the flow by calling /authorize/{session_id}.
  • goidc.StatusFailure or an error: authentication fails and the authorization request is denied.
policy := goidc.NewPolicy(
  "main_policy",
  func(_ *http.Request, _ *goidc.AuthnSession, _ *goidc.Client) bool {
    return true
  },
  func(w http.ResponseWriter, r *http.Request, as *goidc.AuthnSession, _ *goidc.Client) (goidc.Status, error) {
    username := r.PostFormValue("username")
    if username == "" {
      renderHTMLPage(w)
      return goidc.StatusPending, nil
    }

    if username == "banned_user" {
      return goidc.StatusFailure, errors.New("the user is banned")
    }

    as.Subject = username
    return goidc.StatusSuccess, nil
  },
)

op, _ := provider.New(
  ...,
  provider.WithAuthCodeGrant(manager, goidc.ResponseTypeCode),
  provider.WithPolicies(policy),
  ...,
)

For more examples, see the examples folder.

RP-initiated logout is enabled with provider.WithLogout(...).

logoutPolicy := goidc.NewLogoutPolicy(
  "main_logout_policy",
  func(_ *http.Request, _ *goidc.LogoutSession, _ *goidc.Client) bool {
    return true
  },
  func(w http.ResponseWriter, _ *http.Request, _ *goidc.LogoutSession, _ *goidc.Client) (goidc.Status, error) {
    w.WriteHeader(http.StatusNoContent)
    return goidc.StatusSuccess, nil
  },
)

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithLogout(manager, func(w http.ResponseWriter, _ *http.Request, _ *goidc.LogoutSession) error {
    w.WriteHeader(http.StatusNoContent)
    return nil
  }),
  provider.WithLogoutPolicies(logoutPolicy),
)

This enables the logout endpoint, which is /logout by default. A logout request may identify the relying party with client_id or id_token_hint, and may also include post_logout_redirect_uri and state.

When a logout request is accepted, go-oidc creates a goidc.LogoutSession, selects the first matching goidc.LogoutPolicy configured through provider.WithLogoutPolicies(...), and runs it. Like authentication policies, a logout policy can complete immediately or return goidc.StatusPending and resume later through the stored logout session.

On success, go-oidc:

  • redirects to post_logout_redirect_uri when one was provided and validated
  • includes state on that redirect when present
  • otherwise calls the default post-logout handler passed to WithLogout(...)

The first argument to provider.WithLogout(...) is the logout session manager used to store pending logout sessions. Use provider.WithLogoutSessionTimeoutSecs(...) to control how long a pending logout session remains valid, and provider.WithLogoutEndpoint(...) to override the default endpoint path.

ID Tokens

ID tokens are signed JWTs that represent the authentication event for the subject.

go-oidc issues ID tokens from the same goidc.Grant used for access token issuance. Additional ID token claims can be added with provider.WithIDTokenClaims(...).

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithIDTokenClaims(func(_ context.Context, grant *goidc.Grant) map[string]any {
    return map[string]any{
      "acr":   "urn:example:loa:2",
      "roles": grant.Store["roles"],
    }
  }),
)

By default, ID tokens are signed. The provider-side signing and lifetime settings are controlled with:

  • provider.WithIDTokenSignatureAlgs(...)
  • provider.WithIDTokenLifetime(...)

If you want to support encrypted ID tokens, enable it in the provider with:

  • provider.WithIDTokenEncryption(...)
  • provider.WithIDTokenContentEncryptionAlgs(...)

The client metadata can then choose the signing and encryption algorithms it requires through the standard ID token settings.

UserInfo Endpoint

The UserInfo endpoint is enabled by default at /userinfo.

It is called with an access token and returns claims about the authenticated subject. The access token must:

  • be active
  • include the openid scope
  • satisfy any proof-of-possession binding such as DPoP or mTLS

The response starts from the subject in the goidc.Grant. Additional claims can be added with provider.WithUserInfoClaims(...).

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithClaims("email", "name"),
  provider.WithUserInfoClaims(func(_ context.Context, grant *goidc.Grant) map[string]any {
    return map[string]any{
      "email": grant.Store["email"],
      "name":  grant.Store["name"],
    }
  }),
)

By default, the endpoint returns a JSON object. go-oidc only signs or encrypts the UserInfo response when that behavior is enabled in the provider and the client metadata is configured to require it. The provider-side options are:

  • provider.WithUserInfoSignatureAlgs(...)
  • provider.WithUserInfoEncryption(...)
  • provider.WithUserInfoContentEncryptionAlgs(...)

Use provider.WithUserInfoEndpoint(...) to override the default endpoint path.

Token introspection is enabled with provider.WithTokenIntrospection(...).

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithTokenIntrospection(func(_ context.Context, c *goidc.Client, token *goidc.Token) bool {
    return true
  }),
)

This enables the introspection endpoint, which is /introspect by default. The client must authenticate to that endpoint. For each introspection request, the function passed to WithTokenIntrospection(...) is called with the authenticated client and the resolved token and must return whether that client is allowed to introspect it.

For JWT access tokens, introspection validates the token signature and claims directly and resolves the associated goidc.Grant via the grant_id claim. For opaque access tokens, introspection looks up the persisted goidc.Token record and then loads the grant. Refresh token introspection resolves the corresponding goidc.Grant, so the server can report whether that refresh token is still active.

If the token does not exist, is expired, or is otherwise inactive, the endpoint returns an inactive introspection response instead of an OAuth error.

Use provider.WithTokenIntrospectionEndpoint(...) to override the default endpoint path.

Token revocation is enabled with provider.WithTokenRevocation(...).

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithTokenRevocation(func(_ context.Context, c *goidc.Client) bool {
    return true
  }),
)

This enables the revocation endpoint, which is /revoke by default. The client must authenticate to that endpoint. For each revocation request, the function passed to WithTokenRevocation(...) is called with the authenticated client and must return whether that client is allowed to use the revocation endpoint.

In this implementation, refresh token revocation is grant-based. If the refresh token is active and belongs to the authenticated client, the provider revokes the underlying goidc.Grant.

For JWT access tokens, revocation is only effective when provider.WithTokenRevocationRevokeGrantOnAccessToken() is enabled, in which case the provider validates the JWT, resolves the grant via the grant_id claim, and revokes it. Without that option, JWT access token revocation is a no-op since JWTs are not stored server-side.

For opaque access tokens, revocation revokes only the presented token by default. Use provider.WithTokenRevocationRevokeGrantOnAccessToken() if you want access token revocation to also revoke the underlying grant.

If the token does not exist, is already inactive, or cannot be found, the revocation request still succeeds without exposing token state.

Use provider.WithTokenRevocationEndpoint(...) to override the default endpoint path.

Signing and Encryption

When creating a provider.Provider, a JWKS function must be provided. This function returns the keys used for signing and encryption. It should typically return both private and public key material.

Every algorithm configured for the provider must have a corresponding JWK in the JWKS.

key, _ := rsa.GenerateKey(rand.Reader, 2048)
jwks := goidc.JSONWebKeySet{
  Keys: []goidc.JSONWebKey{{
    KeyID:     "key_id",
    Key:       key,
    Algorithm: "RS256",
  }},
}

op, _ := provider.New(
  goidc.ProfileOpenID,
  "http://localhost",
  func(_ context.Context) (goidc.JSONWebKeySet, error) {
    return jwks, nil
  },
)

If direct access to private keys is unavailable or granular control over signing is needed, the JWKS function can return only public key material. In that case, provider.WithSigner must be added:

key, _ := rsa.GenerateKey(rand.Reader, 2048)
jwks := goidc.JSONWebKeySet{
  Keys: []goidc.JSONWebKey{{
    KeyID:     "key_id",
    Key:       key.Public(),
    Algorithm: "RS256",
  }},
}

op, _ := provider.New(
  goidc.ProfileOpenID,
  "http://localhost",
  func(_ context.Context) (goidc.JSONWebKeySet, error) {
    return jwks, nil
  },
  provider.WithSigner(func(_ context.Context, _ goidc.SignatureAlgorithm) (kid string, signer crypto.Signer, err error) {
    return "key_id", key, nil
  }),
)

Similarly, if server-side decryption is needed (e.g., for encrypted JARs), configure provider.WithDecrypter.

ID tokens are signed using RS256 by default. Use provider.WithIDTokenSignatureAlgs to change the default or add additional algorithms.

Access tokens are JWTs by default, signed with the same algorithm used for ID tokens. To customize this, provide a goidc.TokenOptionsFunc:

op, _ := provider.New(
  ...,
  provider.WithTokenOptions(func(_ context.Context, _ *goidc.Grant, _ *goidc.Client) goidc.TokenOptions {
    return goidc.NewJWTTokenOptions(goidc.RS256, 600)
  }),
  ...,
)

Use goidc.NewJWTTokenOptions for JWT access tokens or goidc.NewOpaqueTokenOptions for opaque ones. Opaque tokens require provider.WithOpaqueTokens(...) to be enabled.

Refresh tokens are always opaque.

Scopes

goidc.NewScope creates a scope matched by exact string comparison:

scope := goidc.NewScope("openid")

goidc.NewDynamicScope creates a scope with custom matching logic:

paymentScope := goidc.NewDynamicScope("payment", func(requestedScope string) bool {
  return strings.HasPrefix(requestedScope, "payment:")
})
paymentScope.Matches("payment:30") // true

Dynamic scopes appear by their base name (e.g., "payment") in scopes_supported.

op, _ := provider.New(
  ...,
  provider.WithScopes(goidc.ScopeOpenID, goidc.ScopeOfflineAccess),
  ...,
)

DCR allows clients to register and update themselves dynamically:

op, _ := provider.New(
  ...,
  provider.WithDCR(manager),
  ...,
)

Important: enabling provider.WithDCR(manager) by itself means client creation is open to any caller that can reach the registration endpoint.

Production deployments should usually add provider.WithDCRInitialTokenValidator (goidc.DCRValidateInitialTokenFunc) to require and validate an initial access token during registration.

Use provider.WithDCRClientHandler to implement your registration policy during create and update requests, such as metadata validation, allow/deny rules, or applying default values.

By default, the DCR endpoint is /register and the management endpoint is /register/{client_id}.

To rotate the registration access token on each successful read or update request, add provider.WithDCRTokenRotation().

To rotate client secrets on each successful read or update request for secret-based clients, add provider.WithDCRSecretRotation().

To set a client secret lifetime in seconds for dynamically registered secret-based clients, add provider.WithDCRSecretLifetime(...). Use 0 to indicate that the issued client secret does not expire.

The RP Metadata Choices extension allows clients to advertise priority-ordered lists of preferred algorithms and methods during registration. The server resolves each list to the best mutually supported value.

op, _ := provider.New(
  ...,
  provider.WithDCR(manager),
  provider.WithRPMetadataChoices(),
  ...,
)

A client may include a priority list alongside (or instead of) a single value. For example:

{
  "redirect_uris": ["https://client.example.com/callback"],
  "id_token_signing_alg_values_supported": ["PS256", "RS256", "ES256"]
}

The server selects the first value from the list it supports and returns the resolved value in the registration response:

{
  "client_id": "s6BhdRkqt3",
  "redirect_uris": ["https://client.example.com/callback"],
  "id_token_signed_response_alg": "PS256"
}

If the client also provides the singular field, it must be present in the priority list.

DPoP is enabled with provider.WithDPoP(...) and can be made mandatory with provider.WithDPoPRequired(...).

op, _ := provider.New(
  ...,
  provider.WithDPoP(goidc.ES256),
  ...,
)

When a valid DPoP proof is sent, go-oidc binds the resulting grant and tokens to the proof key thumbprint. Tokens issued under that binding are exposed as DPoP tokens instead of bearer tokens.

When a bound token is used later, the client must send a matching DPoP proof again. The server validates the DPoP JWT and checks that it proves possession of the key associated with the token.

DPoP can participate in multiple stages of the flow depending on what is enabled:

  • authorization requests, including PAR
  • token issuance
  • token usage and proof-of-possession validation

Mutual TLS (mTLS)

mTLS enables client authentication and certificate-bound access tokens via TLS certificates:

op, _ := provider.New(
  ...,
  provider.WithMTLS(
    "https://matls-go-oidc.com",
    func(context.Context) (*x509.Certificate, error) {
      ...
    },
  ),
  ...,
)

All enabled endpoints are listed under mtls_endpoint_aliases in the discovery response:

{
  "mtls_endpoint_aliases": {
    "token_endpoint": "https://matls-go-oidc.com/token"
  }
}

The certificate function (goidc.ClientCertFunc) may be called multiple times per request. Consider caching the result if extraction is expensive.

JAR allows clients to send authorization requests as signed (and optionally encrypted) JWTs:

op, _ := provider.New(
  ...,
  provider.WithJAR(goidc.RS256, goidc.PS256),
  ...,
)

This adds the following to the discovery response:

{
  "request_parameter_supported": true,
  "request_object_signing_alg_values_supported": ["RS256", "PS256"]
}

To enable encryption:

provider.WithJAREncryption(goidc.RSA_OAEP_256)

To customize content encryption algorithms, use provider.WithJARContentEncryptionAlgs.

JARM returns authorization responses as signed (and optionally encrypted) JWTs, adding the response modes jwt, query.jwt, and fragment.jwt.

op, _ := provider.New(
  ...,
  provider.WithJARM(goidc.RS256, goidc.PS256),
  ...,
)

If provider.WithFormPostResponseMode() is also enabled, JARM adds form_post.jwt as well.

To enable encryption:

provider.WithJARMEncryption(goidc.RSA_OAEP_256)

To customize content encryption algorithms, use provider.WithJARMContentEncryptionAlgs.

RAR allows clients to request fine-grained access using structured authorization_details objects. Each detail has a type field that maps to a registered handler.

op, _ := provider.New(
  ...,
  provider.WithRAR("payment_initiation"),
  ...,
)

When using the authorization_code or refresh_token grant types, the client may request a subset of the originally granted authorization details. Provide provider.WithRARDetailsComparator to enforce consistency between the granted and requested sets:

provider.WithRARDetailsComparator(func(ctx context.Context, requested, granted []goidc.AuthDetail) error {
  // Verify that every requested detail is consistent with the granted ones.
  return nil
})

Resource Indicators are enabled with provider.WithResourceIndicators(...).

op, _ := provider.New(
  ...,
  provider.WithResourceIndicators(
    "https://api.example.com",
    "https://ledger.example.com",
  ),
  ...,
)

This allows clients to send the resource parameter in authorization and token requests. The configured values are the only resource indicators the provider accepts.

When enabled, the granted resources are carried in the resulting goidc.AuthnSession, goidc.Grant, and goidc.Token. Access token responses also return the selected resources.

When using authorization_code, refresh_token, device_code, or CIBA token requests, the client may ask for a subset of the originally granted resources. The provider rejects resources that were not granted or that are not part of the configured allowed list.

Use provider.WithResourceIndicatorsRequired(...) if every authorization request must include a resource parameter.

OpenID Federation establishes trust dynamically through signed entity statements, allowing federated clients to authenticate without prior manual registration.

op, _ := provider.New(
  ...,
  provider.WithOpenIDFederation(
    nil,
    func(_ context.Context) (goidc.JSONWebKeySet, error) {
      return fedJWKS, nil
    },
    []string{"https://intermediate.example.com"},
    []string{"https://trust-anchor.example.com"},
  ),
  ...,
)

The entity configuration is exposed at GET /.well-known/openid-federation.

Client Registration Types

Federated clients can use automatic or explicit registration:

provider.WithOpenIDFedClientRegistrationTypes(
  goidc.ClientRegistrationTypeAutomatic,
  goidc.ClientRegistrationTypeExplicit,
)

With automatic registration, the provider resolves the trust chain by fetching entity configurations and subordinate statements. With explicit registration, the client provides the trust chain directly.

Trust Marks

Require specific trust marks from clients:

provider.WithOpenIDFedRequiredTrustMarks(
  func(_ context.Context, _ *goidc.Client) []goidc.TrustMark {
    return []goidc.TrustMark{"https://trust-anchor.example.com/marks/certified"}
  },
)

Include trust marks in the provider's entity configuration:

provider.WithOpenIDFedTrustMark(
  "https://trust-anchor.example.com/marks/certified",
  "https://trust-mark-issuer.example.com",
)

Additional Options

provider.WithOpenIDFedSignatureAlgs(goidc.RS256, goidc.PS256)
provider.WithOpenIDFedTrustChainMaxDepth(5)
provider.WithOpenIDFedOrganizationName("Example Organization")
provider.WithOpenIDFedHTTPClientFunc(func(_ context.Context) *http.Client {
  return customHTTPClient
})

Shared Signals Framework (SSF)

The Shared Signals Framework allows the provider to act as an SSF transmitter, publishing Security Event Tokens (SETs) to receivers. go-oidc supports CAEP and RISC event types.

op, _ := provider.New(
  ...,
  provider.WithSSF(
    func(_ context.Context) (goidc.JSONWebKeySet, error) {
      return ssfJWKS, nil
    },
    func(ctx context.Context) (goidc.SSFReceiver, error) {
      return goidc.SSFReceiver{ID: "receiver"}, nil
    },
  ),
  provider.WithSSFEventTypes(goidc.SSFEventTypeCAEPSessionRevoked, goidc.SSFEventTypeCAEPCredentialChange),
  provider.WithSSFDeliveryMethods(goidc.SSFDeliveryMethodPoll, goidc.SSFDeliveryMethodPush),
  ...,
)

The transmitter configuration is exposed at GET /.well-known/ssf-configuration.

Push delivery (RFC 8935) sends SETs to a receiver-provided endpoint. Poll delivery (RFC 8936) lets receivers fetch pending events from /ssf/poll.

To publish events:

op.PublishSSFEvent(ctx, streamID, goidc.SSFEvent{
  Type: goidc.SSFEventTypeCAEPSessionRevoked,
  Subject: goidc.SSFSubject{
    Format: goidc.SSFSubjectFormatEmail,
    Email:  "user@example.com",
  },
})

Additional options:

// Allow receivers to update stream status (enabled/paused/disabled).
provider.WithSSFEventStreamStatusManagement()
// Allow receivers to add/remove subjects from a stream.
provider.WithSSFEventStreamSubjectManagement()
// Allow receivers to request verification events.
provider.WithSSFEventStreamVerification(func(ctx context.Context, streamID string, opts goidc.SSFStreamVerificationOptions) error {
  // Schedule the verification event for async delivery.
  return nil
})

For production, replace the in-memory SSF storage with persistent implementations using provider.WithSSFEventStreamManager and provider.WithSSFEventPollManager.

For a complete example, see examples/ssf.

The form_post response mode is enabled with provider.WithFormPostResponseMode().

When enabled, authorization responses can be returned to the client redirect URI through an auto-submitted HTML form using the HTTP POST method with application/x-www-form-urlencoded parameters.

manager := storage.NewManager(1000)

op, _ := provider.New(
  "http://localhost",
  manager,
  jwksFunc,
  provider.WithAuthCodeGrant(manager, goidc.ResponseTypeCode),
  provider.WithFormPostResponseMode(),
)

Clients can then request response_mode=form_post at the authorization endpoint.

About

A configurable OpenID Provider built in Go.

Resources

License

Contributing

Stars

Watchers

Forks

Sponsor this project

 

Packages

 
 
 

Contributors