Skip to contents

One login, different API tokens

A calendar service and a contacts service can require different access tokens, even when the same person uses both. A token target names an API and the permissions the app needs there. The app chooses the target when requesting a token, without requiring a separate login flow for each API.

Targets rely on provider support for RFC 8707 resource indicators or Microsoft’s API-specific scopes. The provider’s registration and consent determine which APIs the login can access.

For independent logins or different accounts, use Multiple authorizations. For initial login setup, start with Usage.

Configure two APIs

This example uses a provider with RFC 8707 support. A resource identifier names an API to the login provider; a scope names a permission. resource_bases lists the actual HTTP addresses the app may call, which can differ from the resource identifiers.

Replace all example settings with your provider’s registered values. Microsoft uses the alternative setup shown below.

# Provider supports RFC 8707 resource indicators.
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",
  token_targets = list(
    calendar = oauth_token_target(
      resource = "https://calendar.example/",
      scopes = c("calendar.read", "calendar.write"),
      required_scopes = "calendar.read",
      resource_ids = "calendar_api"
    ),
    contacts = oauth_token_target(
      resource = "https://contacts.example/",
      scopes = "contacts.read",
      resource_ids = "contacts_api"
    )
  ),
  token_target_default = "calendar",
  resource_bases = c(
    calendar_api = "https://calendar.example/v1",
    contacts_api = "https://contacts.example/v1"
  )
)

token_target_default chooses the API when a call omits target. oauth_token_target() checks each declaration’s fields; plain named lists with the same fields remain supported. The client checks provider-specific rules and how declarations relate to one another. Omitting client scopes uses the union of API permissions declared by the targets. If you supply scopes, include every target’s permissions: that explicit list remains the limit and is never expanded automatically. Identity and refresh consent, such as profile or offline_access, still need an explicit request; OIDC continues to add openid when needed. resource_ids connects each target to its named API address in resource_bases. You can omit those addresses when only exporting tokens to another package.

Choose a token when making a call

Get a connection from the module and pass target to select the API. Replace existing_contacts_sdk() with your contacts package’s client constructor:

server <- function(input, output, session) {
  auth <- oauth_module_server(
    "auth", client,
    reauth_after_seconds = 60 * 60 # One-hour maximum login age
  )
  # 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))
    )
  })
}

$access_token() obtains or refreshes the chosen token when needed. $has_scopes() checks permissions already recorded; it can return FALSE before that target’s first token acquisition. $targets() lists target status without exposing credentials.

Use $is_usable(target = "contacts") to check whether that API’s current token is usable, or $summary(target = "contacts") to inspect its status and expiry. These reads never acquire or refresh a token; an unacquired target has status "not_acquired" and is not usable.

The example limits login to one hour with reauth_after_seconds. Without a limit, local authentication can outlast every API token; indefinite_session = TRUE disables that limit.

Refresh and sign in again

For RFC 8707 providers, reduce one target’s permissions with, for example, connection$refresh(scopes = "calendar.read", target = "calendar"). The selected scopes must include the target’s required permissions.

auth$reauthorize() starts a replacement login with the reduced permissions. For managed connections, pass the connection ID. Once replacement starts, cancelling does not restore the old login. An ordinary new login asks for the client’s original permissions again.

Microsoft example

Microsoft uses API-qualified scope names to select the token’s intended API. Use the exact names from your registration. This example exports tokens to another package:

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 = oauth_token_target(
      resource = "https://graph.microsoft.com",
      scopes = "https://graph.microsoft.com/Calendars.Read"
    ),
    contacts = oauth_token_target(
      resource = "api://your-contacts-api",
      scopes = "api://your-contacts-api/Contacts.Read"
    )
  ),
  token_target_default = "calendar"
)

userinfo_required = FALSE skips the Graph profile request, which would be inappropriate for a token intended for another API. Add resource_bases and each target’s resource_ids if you also want to call $request().

Advanced: provider support and declaration limits

Targets share a login and refresh credential but keep separate API tokens and permission limits. Enable token_target_mode = "rfc8707" or "microsoft" only after confirming provider support; discovery does not infer it.

Targets require userinfo_required = FALSE and cannot be combined with SMART configuration or the client’s resource argument. Use the module’s connection() method, since the older oauth_connection() wrapper does not support targets. Up to 16 targets are supported.

For OIDC, put identity scopes such as openid, profile and email at client level. offline_access requests refresh consent through client scopes; it is not an API permission and is rejected in required_scopes. The provider still decides whether to issue a refresh token.

When narrowing a target, retain any identity and refresh-consent scopes you still need. Later refreshes preserve reductions; a replacement OIDC login still requests openid for its new identity check.

connection$identity() returns the last validated identity, which can outlast an API token. Each target’s token and request methods check current API access.

Microsoft can return previously consented permissions beyond those needed for the current operation. Connection methods enforce the app’s local limits, but an exported token carries the provider’s full grant.

Explicit API narrowing with $refresh(scopes = ...) fails with unsupported_scope_narrowing and leaves the login usable. Change provider consent or use a separate registration if the token itself needs fewer permissions.

Resource identifiers and scope names

Use the exact registered resource identifier, including any trailing slash, and fully qualified scope names. Resources can be URIs or GUIDs. The package rejects distinct resources with overlapping prefixes because scope names would be ambiguous. Microsoft permission checks ignore ASCII case; resource identifiers are exact.

Static permissions and unusual short names

Use resource/.default as a target’s sole API scope for static consent, and include it in client scopes. Put actual permission names in required_scopes; .default itself is not evidence that a permission was granted.

If the provider returns unusual short permission names containing : or /, declare them in scope_aliases so the package can interpret the response.

See Microsoft’s scope and consent guidance and refresh-token guidance.

Advanced: concurrent refreshes and failures

Asynchronous acquisitions coordinate use of the shared refresh credential. A synchronous call reports refresh_pending while that work is in progress. A failure that may have consumed the credential can require a new login for the whole connection, even if another API token still works.

With indefinite_session = TRUE, the single module can retain usable cached tokens after such a failure, but retires the refresh credential. It cannot refresh or acquire another target without a new login. The connection manager does not have this exception. Local authenticated state alone does not prove that a selected API token is usable.

Request customization and background work

$request(refresh = TRUE) runs synchronously, checks destination and permissions, and obtains usable credentials before sending. It does not replay a failed API operation or start a new login. Use $request() for DPoP or mTLS-bound tokens; $access_token() exports bearer tokens only.

Logout, replacement and session end invalidate old connections and late results. For SDK callbacks and background-result checks, see External integrations.