# Ovstorage User Authenticate To Backend

> Use when configuring credentials or driving interactive authentication for a direct ovstorage backend connection.

- Skill: `nvidia-omniverse/ovstorage-user-authenticate-to-backend` (Agent Skill)
- Install (CLI): `npx skillmds@latest add nvidia-omniverse/ovstorage-user-authenticate-to-backend`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nvidia-omniverse/ovstorage-user-authenticate-to-backend/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: CC-BY-4.0
- Author: NVIDIA-Omniverse (https://skillmd.com/u/nvidia-omniverse)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/nvidia-omniverse/ovstorage-user-authenticate-to-backend

---


# Authenticate to a Backend

## Goal

Create or refresh credentials for a backend connection without leaking secrets
into logs, route metadata, or tool output.

## When to use this

Use this when adding a new direct-mode connection (`file`, `http`,
`omniverse-storage-service`, `s3`, `gcs`, `azure`, `opendal`, or any
other backend kind), or when an existing connection reports
`AuthRequired`, `CredentialExpired`, or an inactive `auth_state_kind`
in `ovstorage_doctor`.

## Recipe

1. Inspect the configured backend layers and connections with
   [`ovstorage-user-getting-started`](../ovstorage-user-getting-started/SKILL.md).
2. Read the backend descriptor and credential schema before prompting for
   anything secret. Stack callers get this from the root Layer's
   `list_kinds`; CLI users can use the `connect` wizard.
3. For `file`, no external credential is normally required. Register the root
   path and keep credentials empty.
4. For `http`, prefer public URLs where they exist. If an HTTP route needs
   credentials, pass them through the connection credential schema —
   `bearer_token`, `username` + `password`, `signed_query`, or
   `secret_headers` — never through the address string; a query in `root_url`
   or `prefix` is rejected as `InvalidArgument` at `instantiate`.
5. If `authenticate_connection` returns an auth event stream, surface
   `OpenBrowser`, `DeviceCode`, `Progress`, `Succeeded`, `Failed`, and
   `Cancelled` events to the user exactly as events, then stop on a terminal
   event.
6. If it returns `Unsupported`, no flow ran and the connection's state is
   unchanged, so keep the registration and report the state rather than
   treating it as a failure. Backends that always answer this way: `s3`,
   `gcs`, `azure`, `opendal`; broker connections on a direct endpoint (any
   address that is not `http(s)://`); and `file` and `http`, which have no
   connection-auth driver at all. `Unsupported` can also come from a layer
   that does not implement the call — a credentialless visibility-override row,
   or a remote service that has not implemented the RPC — so read it as "no flow
   was offered here", not as a statement about the backend kind. Where a
   credential is what is missing, supply one the origin accepts; re-running
   the flow is not the fix.

## Backend-specific auth

### `omniverse-storage-service`

The `omniverse-storage-service` backend authenticates over OIDC when its
`address` names a discovery root. The plugin reads `/api/v1/auth-config`
from that root to learn the OIDC issuer and client; the standard OIDC discovery doc supplies the
auth / token / device endpoints.

Two interactive flows:

- **Browser** (`InteractiveAuthCapability::Browser`): PKCE with a
  loopback redirect. The plugin opens the user's browser to the
  authorization endpoint, captures the code at a transient localhost
  callback, and exchanges it for the token bundle.
- **Headless** (`InteractiveAuthCapability::Headless`): RFC 8628
  device flow. The plugin requests a device code, surfaces a
  user-code + verification URL via `AuthEvent`, and polls the token
  endpoint until the user completes the flow on a separate device.

In both cases the resulting `OAuthToken` lands as the connection's
`oauth` credential bundle. The plugin proactively refreshes the access
token in a background task at ~90% of the token's TTL using the
refresh-token grant (or the client-credentials grant if that's the
configured method). No intervention required for long-running processes.

To pre-seed credentials (e.g. for CI), populate `oauth` with a long-lived
refresh token; the plugin will exchange it for an access token on first
use.

When `address` names a `grpc://` or `grpcs://` endpoint instead, there is
no discovery root and therefore no OIDC configuration: no sign-in flow,
no client-credentials grant, and no background refresh. Such a connection
takes an access token the host already holds — put it in the `oauth`
field with no refresh token — and the host rotates it by calling
`update_connection_credentials` again with a fresh one. An empty bundle
removes it.

### `s3`

The descriptor exposes three credential methods:

- `static_key` — long-lived IAM user key. Populate
  `aws_access_key_id` + `aws_secret_access_key`.
- `session` — short-lived STS / SSO credentials. Populate
  `aws_access_key_id` + `aws_secret_access_key` +
  `aws_session_token`.
- `aws_credentials_file` — read from an INI section. Populate
  `file_path` (defaults to `~/.aws/credentials`) and `profile`
  (defaults to `default`); the per-connection `profile` config key
  picks the section.

Each credential field's descriptor default is the matching `${AWS_*}`
env-var placeholder (e.g. `${AWS_ACCESS_KEY_ID}`); the host's
bundle-resolution layer expands these before the bundle reaches the
plugin. From the plugin's point of view the resolution chain is
bundle fields → env vars (via placeholder expansion) → shared
credentials file. IMDS, AWS SSO, web-identity tokens, and STS role
assumption are not implemented in-process; operators that need them
run their session helper (`aws sso login`, etc.) to materialise a
static-key profile.

### `gcs`

The descriptor exposes two credential methods:

- `service_account_key` — paste a service-account JSON keyfile into
  the `service_account_key` field. The plugin auto-detects the JSON
  `type`: `service_account` triggers JWT-bearer exchange (and is
  the only path that can mint V4-signed read URLs); `authorized_user`
  triggers an OAuth2 refresh-token exchange (reads stream through
  `alt=media` under the cached bearer because authorized-user creds
  cannot V4-sign).
- `gcloud_adc_file` — populate `file_path` with the path to an
  ADC JSON file written by `gcloud auth application-default login`
  (defaults to `~/.config/gcloud/application_default_credentials.json`,
  with `~` expanded against `HOME` / `USERPROFILE` + `HOMEDRIVE`).

The plugin does **not** consult `GOOGLE_APPLICATION_CREDENTIALS` or
any other env var; if you need env-var expansion, do it in the host's
bundle-resolution layer before handing the bundle to the plugin.
GCE/GKE metadata, workload-identity federation
(`external_account`), and service-account impersonation
(`impersonated_service_account`) are not implemented in-process.

### `http`

The descriptor exposes four credential methods. Distinct channels combine —
for example, a connection may hold a signed query and secret headers at once —
and a connection with none of them is anonymous:

- `bearer` — opaque token sent as `Authorization: Bearer <token>`
  on every request. Populate `bearer_token`.
- `basic` — HTTP Basic authentication. Populate both `username` and
  `password`; either value may be empty, but not both.
- `signed_query` — pre-issued query string appended verbatim to every
  request URL (leading `?` optional). Populate `signed_query`, and set
  the `signed_query_scope` **config** key to declare the token's scope
  family.
- `secret_headers` — credential-bearing headers in wire form, one
  `Name: Value` per line, sent with every request. Populate
  `secret_headers`. The separator is a line break, not a comma, so a
  value may contain commas. This is the secret-bearing counterpart to
  the `default_headers` config key, which rejects `Authorization`,
  `Cookie`, and `Proxy-Authorization`; those names are accepted here,
  while `Host`, `Range`, `If-Match`, and the framing and hop-by-hop
  headers are refused.

A signature is a credential with a declared scope, not part of the
address. Writing a query into `root_url` or `prefix` returns
`InvalidArgument` at `instantiate`: the object key is appended after the
route prefix, and a signature buried in config can be neither scoped nor
rotated. `signed_query_scope` accepts `prefix` (the token authorizes
everything under `root_url` — an Azure account, container, or directory
SAS, a CloudFront signed URL with a custom policy) or `object` (a
per-object presign, which a connection cannot hold and which returns
`Unsupported`; dispatch such a URL as a per-request address). The plugin
also refuses a token whose parameters name a per-object signature —
`X-Amz-Signature`, `sr=b`, a CloudFront canned policy — while
`signed_query_scope` says `prefix`. Supplying `signed_query` with no
scope, or a scope with no `signed_query`, is `InvalidArgument`; so is
setting Basic or Bearer credentials together with a `secret_headers` entry
named `Authorization`. Userinfo in `root_url` is the legacy Basic channel and
likewise conflicts with another `Authorization` writer, while it may coexist
with a signed query or non-Authorization secret header.

All channels are hot-swappable through
`update_connection_credentials` while the connection is live, but the
rotation may not change the connection's exact shape: the Authorization
method, presence of a signed query, and secret-header names and multiplicity
must remain fixed. Remove and re-add the connection to change that shape.
`signed_query_scope` is configuration rather than a credential, so rotating
into a signed query without that key (or supplying the key with no token) is
`InvalidArgument`. A replacement is probed before the atomic swap; reads keep
using the old snapshot while the probe runs. Credentials over a plaintext
`http://` route are refused except on loopback, and any secret-bearing
connection refuses an `https://` to `http://` redirect downgrade.

### `azure`

The descriptor exposes four distinct credential methods. The plugin
resolves them in this order:

- `account_key` — Shared Key signing. Populate `account_key`.
- `sas_token` — pre-issued shared-access signature appended verbatim
  to request URLs. Populate `sas_token`.
- `workload_identity` — Entra OAuth2 federated workload identity.
  Populate `federated_token_file` + `client_id` + `tenant_id`.
- `service_principal` — Entra OAuth2 client-credentials. Populate
  `client_id` + `client_secret` + `tenant_id`.

Each credential field's descriptor default is the matching
`${AZURE_*}` env-var placeholder
(`${AZURE_STORAGE_ACCOUNT_KEY}`, `${AZURE_STORAGE_SAS_TOKEN}`,
`${AZURE_TENANT_ID}`, `${AZURE_CLIENT_ID}`, `${AZURE_CLIENT_SECRET}`,
`${AZURE_FEDERATED_TOKEN_FILE}`); the host's bundle-resolution layer
expands these before the bundle reaches the plugin. The plugin
itself never reads `AZURE_*` env vars at runtime. Managed Identity
(IMDS), `az login`, VS Code, and PowerShell credential sources from
`DefaultAzureCredential` are not implemented in-process.

### `opendal`

Auth depends on the chosen `service` (one of `fs`, `s3`, `webdav` —
the only services compiled in). `fs` requires no credentials.
The OpenDAL `s3` driver wants `access_key_id` + `secret_access_key`
(**bare names**, not `aws_*`-prefixed — these are NOT
interchangeable with the first-party `s3` plugin's credential
shape). `webdav` uses HTTP Basic credentials (`password`, plus
`username` passed through `config_json`). The adapter accepts only
`SecretValue::Bytes`, `SecretValue::OAuthToken`, and
`SecretValue::File` for these fields; `SecretValue::SystemIdentity`
and `SecretValue::MtlsCertPair` are rejected as `Unsupported`.
Ambient-identity flows (Kerberos ticket cache, etc.) are not
threaded through. See
[`plugin-opendal.md`](../../docs/public/plugin-storage/plugin-opendal.md)
for per-driver detail.

### `nucleus`

Omniverse Nucleus's auth shape predates the unified OAuth pattern
used by the Omniverse Storage Service and the cloud backends.
The descriptor exposes three user-selectable credential methods:

- **`sso`** — single sign-on (browser). Recommended. Binds no
  credential fields. The plugin (`establish_interactive_auth`)
  calls `Tokens::subscribe` to register a one-shot login intent,
  receives a server-generated `nonce`, and surfaces
  `AuthEvent::OpenBrowser` with the Nucleus login URL annotated
  with `?nonce=<nonce>`. The host UI opens the URL; once the user
  completes the form, the second response on the same subscription
  carries the authentication result. The plugin never touches raw
  passwords or 2FA challenges on this path.
- **`userpass`** — username and password. Binds `username` and
  `password`; drives Nucleus's `Credentials::auth` flow. For
  service accounts and headless deployments.
- **`api_token`** — API token. Binds a single `api_token`
  credential. Drives `Tokens::auth_with_api_token`; takes
  precedence over `username` / `password`. The `Auth` envelope's
  `username` is recorded as the authenticated principal. The
  recommended automation path.

Successful authentication via `sso` produces a refresh token that
the plugin persists in the OS keyring keyed by the *server
hostname* (not the host-minted, unstable `ConnectionId`). On
subsequent connects, warm-continuation reads `refresh_token` from
the keyring, rediscovers `tokens_url`, and exchanges the refresh
token without re-prompting; on a service-call `Expired` /
`InvalidToken`, the plugin invokes `Tokens::refresh` once per
token-generation epoch (concurrent callers share a single refresh
under a per-shared mutex). `AuthRequired` / `AuthExpired` /
`PermissionDenied` clear the keyring entry; `Transient` preserves
it for retry. SSO (`SSO::*`) and DeviceFlow (`DeviceFlow::*`) are
declared as target / deferred flows; they are not wired today.

### `broker`

The `broker` plugin authenticates to an upstream `ovstorage-broker`
daemon; the broker itself holds the long-lived cloud credentials and
runs its own authn substrate against whatever listener-authn mode
operators configured. From the calling host's perspective the
client-side credential surface is intentionally minimal:

- **`client_credentials`** — OIDC client credentials grant. Binds
  `client_id` + `client_secret`. Only valid when the configured
  `address` is a discovery URL (not a direct gRPC endpoint): the
  plugin fetches `/api/v1/auth-config`, follows the published
  `openid_configuration` URL to the IdP's standard OIDC discovery
  doc, and drives a non-interactive client-credentials grant. Suited
  for service accounts and headless deployments.

Interactive flows (PKCE loopback for `Browser`, RFC 8628 device flow
for `Headless`) are driven by `Factory::authenticate` against the
same broker-published `auth-config`. The host signals which mode is
available through the gRPC `x-ov-iauth: browser|headless|none`
metadata header attached by the broker-client SDK on every RPC; the
broker reads this on its streaming `Auth` RPC, threads the parsed
`InteractiveAuthCapability` into the upstream-OAuth flow, and emits
`AuthEvent::OpenBrowser` / `DeviceCode` / `Progress` / `Succeeded`
back to the host. `None` mode causes the broker to emit a terminal
`Failed { AuthRequired }` envelope without opening a browser tab or
device prompt — the fail-fast shape render workers and CI hosts
need.

**Three-tier OAuth, broker-held.** Successful interactive auth lands
a refresh token *on the broker* via the unary `RegisterCredential`
round-trip; the broker persists it through its `OAuthCredentialProvider`
+ `secret_tokens` row + `SecretStore` (per-`(BackendId, PrincipalView)`
slot). The host process holds only the short-lived broker bearer —
never the upstream cloud credentials. Workers and a coordinator that
share a `PrincipalView` at the broker hit the same cache slot, which
covers the multi-tenant SaaS and render-worker shapes without any
host-side coordination.

The broker daemon's listener authn mode (`jwt_verify`,
`trusted_unsigned_jwt`, `trusted_forwarded_headers`, `peer_cred`, or
the reserved `mtls`) decides how the host's bearer is validated —
that's operator-owned config and is not part of the
`broker-client`-side credential schema. See
[`docs/public/broker-operator/README.md`](../../docs/public/broker-operator/README.md)
for the daemon side.

## References

- [`docs/public/library-rust/README.md`](../../docs/public/library-rust/README.md) for
  `add_connection`, credential persistence, and `authenticate_connection`.
- [`plugin-file`](../../docs/public/plugin-storage/plugin-file.md)
  for local filesystem configuration.
- [`plugin-http`](../../docs/public/plugin-storage/plugin-http.md)
  for HTTP route behavior.
- [`plugin-services-client`](../../docs/public/plugin-storage/plugin-services-client.md)
  for the `omniverse-storage-service` OIDC flow.
- [`plugin-nucleus`](../../docs/public/plugin-storage/plugin-nucleus.md)
  for the Omniverse Nucleus auth flows.
- [`plugin-broker`](../../docs/public/plugin-storage/plugin-broker.md)
  for the broker-client Layer surface, discovery URL normalization,
  and the broker-held three-tier OAuth model.
- [`docs/public/broker-operator/README.md`](../../docs/public/broker-operator/README.md)
  for the daemon-side listener authn modes a broker operator
  configures.
- [`ovstorage-user-handle-errors`](../ovstorage-user-handle-errors/SKILL.md) for interpreting
  auth and permission failures.

