Skip to content

Use case — First-party consent skip ​

What is "first-party"? ​

OIDC and OAuth 2.0 are designed for the third-party case: an external RP (some SaaS) wants access to the user's data held by your OP, so the user is asked, "Do you authorise SaaS-X to read your email on your behalf?" OIDC Core 1.0 §3.1.2.4 requires the OP to obtain an authorization decision before releasing information. An interactive dialogue is one way to obtain that decision; prior administrative consent or another established processing condition can also suffice.

First-party clients are RPs you (the OP operator) own — your own mobile app, your own SPA, your own internal dashboard. The user is already in your trust boundary; asking "do you authorise Acme Corp to read your data" inside Acme Corp's own login flow is busywork.

OAuth 2.0 has long left this exemption to the operator's discretion; the upcoming OAuth 2.0 for First-Party Applications draft formalises the pattern. This library exposes it as a per-client allow-list.

Specs referenced on this page
Vocabulary refresher
  • Third-party vs first-party — the axis is ownership of the RP: an app someone else runs against your OP, versus one you ship yourself. Nothing about the protocol changes; what changes is whether the user's veto over the delegation is meaningful.
  • Consent decision — The authorization decision made before the OP releases information. An interactive consent screen is one possible way to obtain it; a prior administrative decision can also establish it. Skipping the screen for first-party RPs trades a UX win for an explicit trust-boundary decision: an attacker who manages to get their RP onto the first-party list bypasses the user's veto entirely.

Source: examples/40-first-party-skip-consent

Wiring ​

go
op.New(
  /* required options */
  op.WithFirstPartyClients("first-party-app", "internal-dashboard"),
  op.WithStaticClients(
    op.PublicClient{
      ID:           "first-party-app",
      RedirectURIs: []string{"https://app.example.com/callback"},
      Scopes:       []string{"openid", "profile", "email"},
      GrantTypes:   []string{"authorization_code", "refresh_token"},
    },
    op.PublicClient{
      ID:           "internal-dashboard",
      RedirectURIs: []string{"https://dashboard.example.com/callback"},
      Scopes:       []string{"openid", "profile"},
      GrantTypes:   []string{"authorization_code", "refresh_token"},
    },
  ),
)

op.New checks the list at construction. Every ID must also be registered through WithStaticClients, otherwise it fails with unknown client_id. The list accepts only those IDs: a client that exists only in your own client store, or one created by Dynamic Client Registration, cannot be first-party, whatever the list says.

For a listed client, /auth creates the consent grant on the user's behalf and issues the code with no consent screen. This holds when the user has to sign in first, either because there is no session or because the request carries prompt=login or needs a step-up: the grant belongs to the subject that sign-in establishes. A client that is not listed still sees the consent prompt. With prompt=none, a listed client gets a code where an unlisted one would get consent_required.

What users still see ​

A listed client does not mean the consent screen never appears. Users still get it when:

  • the request asks for offline_access, or carries prompt=consent, authorization_details or a Grant Management action;
  • the browser did not label the request as coming from your own origin: a typed or bookmarked address sends Sec-Fetch-Site: none, and a link from another site sends cross-site. A navigation started by an RP page on the same site is trusted only when its Origin or Referer matches the origin of the redirect_uri;
  • the request is prompt=create (new registration), prompt=select_account (account chooser), or the chooser's add-account sign-in, because these bind a different subject than the one that is signed in;
  • the client is not on the list, including every self-registered client.

When the skip applies ​

The skip is deliberately narrow. It applies only when all of these hold; otherwise the user sees the normal consent screen.

ConditionWhy
The client is on the listThe operator's decision.
The request is not prompt=create or prompt=select_accountRegistration and the account chooser bind a different subject, so the user confirms.
The request does not carry prompt=consentAn explicit prompt from the RP wins over the OP's posture.
The scope does not include offline_accessA long-lived refresh grant is always confirmed by the user.
The request carries no authorization_details and no Grant Management actionRich authorization requests and grant changes always get an explicit ceremony.
The browser reports a same-origin request, or a same-site one whose Origin or Referer matches the origin of the redirect_uriSec-Fetch-Site guards against a cross-site page triggering the auto-grant. A cross-site navigation, or a client that sends no Fetch Metadata headers, gets the consent screen.

The skip never invents a subject: it grants for the established session's subject, or for the subject the sign-in in the same request binds. It replaces only the consent screen. LoginFlow rules and the Decider are still evaluated before the code is issued, so a rule that guards the client or a scope can demand a step-up (or answer interaction_required under prompt=none), and interactions registered with op.TriggerBeforeToken still run.

Audit ​

Every skip emits audit.ConsentGrantedFirstParty, so the audit trail records "consent was implicit, the client is on the first-party list" instead of an unexplained grant. Dashboards can tell first-party grants from third-party ones by the event type.

Mutual exclusion ​

op.WithFirstPartyClients cannot be combined with a FAPI 2.0 profile. This is a library policy: the profiles reject first-party auto-consent, even though OIDC Core permits an authorization decision established by prior or administrative consent. op.New fails with a configuration error that names FAPI 2.0, so the trust-boundary decision is explicit.

Production caveat ​

Trust extension

Listing a client in WithFirstPartyClients is a deliberate trust extension. Production embedders MUST restrict the list to clients owned by the embedder's organisation, and keep it in reviewed configuration next to the WithStaticClients roster it must match, so adding an ID is a code-review event. If a third-party client gets onto the list, the user's veto is gone.