Several API tokens from one authorization

A token target names the resource and API scopes for one access token. Several targets can share a user’s local authorization and refresh credential while keeping separate access-token responses. This differs from several independent authorizations, which continue to use oauth_connections_server() and separate connection IDs.

The provider must explicitly support the acquisition protocol. A declaration is a request limit, not evidence of consent. The server can refuse a target or omit the refresh credential needed to acquire it. No target method starts sign-in, switches accounts or tenants, or implements token exchange/OBO.

When adding targets to an existing oauth_module_server() app, choose an explicit authentication lifetime. Target authorizations can keep auth$authenticated true after all access tokens expire, even without a refresh token and with indefinite_session = FALSE. The default reauth_after_seconds = NULL leaves that local login available for the remaining Shiny session. If local application data is protected with req(auth$authenticated), set a finite reauth_after_seconds appropriate for the app, as in the example below. indefinite_session = TRUE ignores that age limit. Connection methods still enforce the selected access token’s expiry and permissions.

Declare calendar and contacts targets

# Use this mode only after confirming RFC 8707 support with your provider.
provider <- oauth_provider(
  "workplace", "https://login.example/authorize", "https://login.example/token",
  token_target_mode = "rfc8707",
  token_auth_style = "body",
  userinfo_required = FALSE
)
client <- oauth_client(
  provider,
  client_id = Sys.getenv("OAUTH_CLIENT_ID"),
  client_secret = Sys.getenv("OAUTH_CLIENT_SECRET"),
  redirect_uri = "https://app.example/callback",
  scopes = c("calendar.read", "calendar.write", "contacts.read"),
  token_targets = list(
    calendar = list(
      resource = "https://calendar.example/",
      scopes = c("calendar.read", "calendar.write"),
      required_scopes = "calendar.read",
      resource_ids = "calendar_api"
    ),
    contacts = list(
      resource = "https://contacts.example/",
      scopes = "contacts.read",
      resource_ids = "contacts_api"
    )
  ),
  default_token_target = "calendar",
  resource_bases = c(
    calendar_api = "https://calendar.example/v1",
    contacts_api = "https://contacts.example/v1"
  )
)

Several targets require an explicit default before sign-in. It determines code redemption and later default accessors. With one target, oauth_client() selects it automatically. Resource indicators and HTTP destinations are different concepts: resource_ids explicitly authorizes a target’s use at a named resource_bases destination. Equal names never create an implicit association. Omit HTTP bases and associations if the application only exports bearer tokens to another library.

Configure at most 16 targets. The complete authorization has a shared limit of 128 distinct scope tokens and 8192 scope bytes, excluding separating spaces. OIDC scopes count toward these limits. Configuration, acquired permissions (including Microsoft .default expansion), restoration and reauthorization enforce the same aggregate limits. Reduce the declared permissions or use separate authorizations when the combined set would exceed them. Managed transaction contexts support this full budget, including repeated limits across targets, and are validated before reauthorization ends the current connection. The independent callback and encrypted-state size limits still apply; a large declaration can require larger state limits even when its scope count fits.

RFC 8707 authorization requests carry all declared resources and the client’s scopes. Code redemption and refresh carry one selected resource and its scopes. The server determines which permissions apply to each resource; scopes in RFC 8707 are not independently paired with individual resources in the authorization request. See RFC 8707.

Declare OIDC scopes at client level, outside the targets’ API scope lists. RFC 8707 mode recognizes openid, profile, email, address, phone, and offline_access when the provider uses OIDC or client scopes includes openid. For an OAuth-only provider without openid, these names are ordinary API scopes: declare them in the relevant target, including target required_scopes when needed. They are not automatically sent to sibling targets or retained as refresh consent. For OIDC clients, all except offline_access can also be client-level required_scopes. Request offline_access in client scopes for refresh consent; putting it in client or target required_scopes is a configuration error. A refresh token can be issued without this value appearing in the access-token scopes, and its presence in scopes does not establish that a usable refresh credential exists. See OpenID Connect scope values. Microsoft mode keeps its provider-specific set: openid, profile, email, and offline_access. It does not treat bare phone or address as Microsoft OIDC scopes.

Use the same connection interface in either module

Obtain target connections from the module’s connection() factory. The legacy oauth_connection(client, token_reactive) wrapper rejects target clients because the token alone does not contain their retained permission limits.

server <- function(input, output, session) {
  auth <- oauth_module_server(
    "auth", client,
    reauth_after_seconds = 60 * 60 # Example: choose the app's login lifetime.
  )
  # Alternatively: auth <- oauth_connections_server("auth", manager)
  # and select auth$connection(connection_id), or auth$connection() when unique.

  contacts <- shiny::reactive({
    connection <- shiny::req(auth$connection())
    token <- connection$access_token("contacts.read", target = "contacts")
    existing_contacts_sdk(access_token = token)$list_contacts()
  })

  output$can_edit_calendar <- shiny::renderText({
    connection <- shiny::req(auth$connection())
    connection$has_scopes("calendar.write", target = "calendar")
  })

  shiny::observeEvent(input$save_event, {
    connection <- shiny::req(auth$connection())
    connection$request(
      "calendar_api", "events", method = "POST",
      required_scopes = "calendar.write", target = "calendar", refresh = TRUE,
      configure = function(req) httr2::req_body_json(req, list(title = input$title))
    )
  })
}

The first contacts access can acquire its token even though $has_scopes() was FALSE before that target had evidence. $targets() returns target names, acquisition status and granted scope evidence, never credentials. Operation required_scopes checks do not change the acquisition request. An explicitly partial response remains that target’s ceiling on later refreshes.

connection$identity() returns selected fields from the last validated OIDC identity while the authorization remains available in the owning session. It can still succeed after the primary access token expires or its current scopes no longer include openid. This snapshot does not establish fresh user authentication or current API access. Use has_scopes() and access_token() or request() to check the selected target’s permissions and token lifetime.

Each ceiling includes the OIDC scopes granted to that target. Explicit refresh narrowing uses exactly the supplied scopes, so retain any OIDC permissions still needed, for example scopes = c("calendar.read", "openid"). Required scopes must remain present. Optional identity scopes such as email and profile are not added back on refresh or restoration. For OIDC and Microsoft clients, requested offline_access is retained separately from access-token permission evidence: providers can omit it from token scopes while issuing a refresh token. This preserves refresh capability on reauthorization without reporting offline_access as a granted access-token permission. Explicitly removing it from a refresh request removes it from that target’s retained limit as well. Reauthorization requests the union of the retained target limits; an OIDC scope still retained by another target can therefore remain in that authorization request.

OIDC reauthorization always requests openid for the new validated login, including code redemption for the default target. This mandatory protocol scope is restored in that new target’s request limit even if the previous access-token response omitted it or a refresh removed it. Removed API permissions and optional OIDC scopes (profile, email, address, phone, offline_access) remain excluded. Secondary targets keep their retained limits.

For RFC 8707, connection$refresh(scopes = "calendar.read", target = "calendar") deliberately narrows calendar permissions. Acquiring contacts does not restore calendar write access. Module reauthorize() retains all target ceilings and replaces the connection ID; the manager takes an ID for this operation. To request a new authorization with the original configuration after deliberate narrowing, use the normal new-login flow rather than reauthorization of the restricted grant.

Both modules check replacement state against the callback and encrypted-state limits before ending the current authorization. A local preflight rejection leaves the connection usable and creates no pending login. Once replacement starts, failure or cancellation does not restore the old authorization.

The cache and refresh credential belong to one authorization. Async accessors for the same operation share work; different target acquisitions queue and read the most recently committed refresh credential. A synchronous caller reports refresh_pending while async work owns the credential. Safe target rejections leave other entries usable. An ambiguous shared refresh outcome can invalidate the whole authorization. Independent authorizations with equal refresh-token bytes still follow the manager’s conservative alias-invalidation rules.

With indefinite_session = TRUE, the single module retains cached target access tokens after an ambiguous refresh failure, but retires the shared refresh credential. A cached token can still be exported while it meets the operation’s lifetime and scope requirements. Forced refresh, expired tokens, and targets not yet acquired require a new authorization; they cannot reuse the retired credential. token_stale flags the failed refresh even while cached tokens are usable. The manager has no indefinite-session exception.

The single module keeps a target authorization available for acquisition when one access token expires. Its session and configured authorization-age limits still apply. Its token_stale flag reports an expired primary token, and refreshing a secondary target does not clear that flag. Its authenticated flag tracks that retained authorization; connection methods check the selected token’s lifetime and permissions before use. Recoverable target failures are returned to the caller without changing the module’s authentication or starting a new login. A successful token rotation preserves the connection ID; logout, replacement and session end invalidate old references and late completions. Targets are stored inside the manager’s existing encrypted credential envelope.

Single-module logout and configured session-end revocation remove local access before contacting the provider. Remote target cleanup is best effort, with a shared ten-second deadline and a two-second timeout per request without retries. The shared refresh credential is attempted first. With async = TRUE, one worker handles the batch; time spent waiting for a worker also counts toward the deadline. Targets remaining after the deadline are not attempted.

$request(refresh = TRUE) is synchronous. It validates the destination, method, query and unauthenticated request before acquiring tokens. The configure function runs once; authorization is checked again before the request is sent. It disables generic HTTP retries for that call. It never refreshes or replays an application request after a resource error. DPoP nonce challenges retain the existing bound-transport handling. Export through $access_token() remains bearer-only; use $request() for DPoP/mTLS tokens. Writes belong in input-event handlers and require another user action after an error.

Microsoft configuration

provider <- oauth_provider_microsoft(
  tenant = Sys.getenv("MICROSOFT_TENANT_ID"),
  userinfo_required = FALSE
)
client <- oauth_client(
  provider, Sys.getenv("OAUTH_CLIENT_ID"), Sys.getenv("OAUTH_CLIENT_SECRET"),
  redirect_uri = "https://app.example/callback",
  scopes = c(
    "openid", "offline_access",
    "https://graph.microsoft.com/Calendars.Read",
    "api://your-contacts-api/Contacts.Read"
  ),
  token_targets = list(
    calendar = list(
      resource = "https://graph.microsoft.com",
      scopes = "https://graph.microsoft.com/Calendars.Read"
    ),
    contacts = list(
      resource = "api://your-contacts-api",
      scopes = "api://your-contacts-api/Contacts.Read"
    )
  ),
  default_token_target = "calendar"
)

The preset enables Microsoft’s scope-based acquisition mode. Authorization requests include the declared consent scopes; token requests select one API’s qualified scopes plus its retained OIDC scopes (initially the configured OIDC scopes), without an RFC 8707 resource parameter. Disabling UserInfo avoids calling Graph with another API’s token and preserves ID-token validation. Microsoft resource identifiers can be absolute URIs or GUIDs, for example 499b84ac-1321-427f-aa17-267ca6975798 with scope 499b84ac-1321-427f-aa17-267ca6975798/.default. RFC 8707 mode still requires absolute URIs. Resource identifiers are exact: where an identifier ends in /, appending /.default produces a double slash. Each API scope and required API scope must contain a permission name after the exact resource prefix and its separating slash. A bare prefix such as api://resource/ is rejected during configuration. Distinct Microsoft resource identifiers must not have overlapping scope prefixes. For example, https://api.example and https://api.example/reports cannot be declared together: reports/read could be a permission of the first resource or read of the second. Configuration rejects this ambiguity before authorization. Targets may share the exact same resource identifier with separate scope limits. See the Microsoft code flow.

For static consent, declare resource/.default as the target’s sole API scope, also in client scopes. Do not mix static and explicit API scope declarations. Target required_scopes must name actual API permissions; .default is a consent request, so it is rejected as a required access-token permission. Scope evidence from tokens, introspection, and restored credentials also rejects case variants such as .DEFAULT; configure static consent using the canonical .default. Microsoft static consent concerns the application’s registered permissions for all APIs; shinyOAuth sends one static scope at authorization and selects the desired resource at token redemption. During reauthorization, the sealed target limits still constrain token acquisition even if static consent remains needed for an unacquired target.

If a partial grant leaves a Microsoft target with only OIDC scopes, further acquisition fails with insufficient_scope before sending credentials. OIDC-only requests would select Graph/UserInfo instead of that API. Reauthorization also rejects an API-empty default target during preflight, preserving current access; use a new login to request the originally configured API permissions.

.default is never treated as proof of individual permissions. Microsoft target responses must include scope; returned short permission names are qualified only within the selected Microsoft resource. The same normalization applies to scope checks when a custom introspection endpoint is configured, for both login and refresh. Public introspect_token() results retain the raw provider scope string. Short names containing : or / must match that target’s declared scopes, required permissions, optional scope_aliases, or retained permissions from an earlier grant. For example, declare scope_aliases = c("items/read", "read:items") inside a target to recognize these optional .default or prior-consent permissions. Aliases are short names relative to that target’s resource; they do not go into authorization or token requests, make a permission required, or establish consent. Only actual returned permissions can enter the grant, and existing operation limits still apply. Names with :// and declared resource prefixes remain resource-qualified; unknown punctuated short names must be returned in fully qualified form. Token evidence records the actual returned permissions. Microsoft API permission comparisons ignore ASCII case (for example, User.Read and user.read); resource identifiers remain exact. Ordinary OAuth scopes remain case-sensitive. Local operation limits retain only permissions within the previous limit (expanding .default to its initial granted permissions), so a later response cannot restore a locally removed operation. See Microsoft scopes and static consent and refresh credentials.

Entra can return all previously consented API permissions for the resource, even when the request asks for fewer. Such responses are accepted without enlarging the local operation limit. $has_scopes(), $request() and operation requirements on $access_token() still enforce that limit, including during refresh and after restoration. $targets() reports actual granted scopes, which can therefore exceed the permissions available through these checks.

An exported bearer token still carries its full provider-issued permissions; shinyOAuth cannot restrict what an external SDK does with it. Explicit Microsoft API narrowing through $refresh(scopes = ...) fails before sending credentials, with reason unsupported_scope_narrowing. The existing authorization stays usable. Use a separate app registration or change provider consent when you need a token with fewer API permissions. See Microsoft consent lifetime.

These modes are covered by local HTTP contract tests, including rotation and scope selection. Those tests do not establish compatibility with every RFC 8707 deployment or replace validation of your Microsoft registration and consent. Discovery alone does not enable targets. SMART target switching, provider plugins, distributed stores and incremental consent are outside this interface.

For SDK callbacks, event-bound result ownership and background tasks, see vignette("external-integrations").