Package {tinyoauth}


Type: Package
Title: Minimal OAuth 2.0 Client
Version: 0.1.0
Date: 2026-06-10
Description: A dependency-light OAuth 2.0 https://www.rfc-editor.org/rfc/rfc6749 client supporting the client-credentials and authorization-code grants with token refresh. Built on 'curl' and 'jsonlite', with base R's socket server for the redirect listener, avoiding heavier HTTP stacks. Includes a helper for the 'OpenAI' https://openai.com/ 'Codex' device login, a similar but non-standard variant of the OAuth 2.0 device authorization grant.
License: MIT + file LICENSE
Encoding: UTF-8
Depends: R (≥ 4.0)
Imports: curl, jsonlite
Suggests: tinytest
URL: https://github.com/cornball-ai/tinyoauth
BugReports: https://github.com/cornball-ai/tinyoauth/issues
NeedsCompilation: no
Packaged: 2026-06-10 06:05:34 UTC; troy
Author: Troy Hernandez ORCID iD [aut, cre], Sounkou Mahamane Toure [ctb], cornball.ai [cph]
Maintainer: Troy Hernandez <troy@cornball.ai>
Repository: CRAN
Date/Publication: 2026-06-17 17:40:02 UTC

Minimal OAuth 2.0 Client

Description

A dependency-light OAuth 2.0 <https://www.rfc-editor.org/rfc/rfc6749> client supporting the client-credentials and authorization-code grants with token refresh. Built on 'curl' and 'jsonlite', with base R's socket server for the redirect listener, avoiding heavier HTTP stacks. Includes a helper for the 'OpenAI' <https://openai.com/> 'Codex' device login, a similar but non-standard variant of the OAuth 2.0 device authorization grant.

Package Content

Index of help topics:

oauth_authorize_url     Build an authorization URL
oauth_bearer            Authorization header value for a token
oauth_cache_path        Default on-disk cache path for a client's token
oauth_client            Define an OAuth 2.0 client
oauth_exchange_code     Exchange an authorization code for a token
oauth_expired           Is a token expired?
oauth_import_httr       Import an httr '.httr-oauth' cache into
                        tinyoauth
oauth_jwt_payload       Decode a JWT payload
oauth_refresh           Refresh an access token
oauth_request           Make an authenticated request
oauth_token             Get a valid token, using the cache and
                        refreshing as needed
oauth_token_authcode    Run the authorization-code flow end to end
oauth_token_client      Fetch a token via the client-credentials grant
oauth_token_openai_codex
                        Get a valid OpenAI Codex token, using the cache
                        and refreshing as needed
openai_codex_account_id
                        Extract the ChatGPT account id from a Codex
                        token
openai_codex_client     OAuth client for the OpenAI Codex (ChatGPT)
                        device-login flow

Maintainer

Troy Hernandez <troy@cornball.ai>

Author(s)

Troy Hernandez [aut, cre] (ORCID: <https://orcid.org/0009-0005-4248-604X>), Sounkou Mahamane Toure [ctb], cornball.ai [cph]


Build a tinyoauth_token from a parsed token response

Description

Build a tinyoauth_token from a parsed token response

Usage

.as_token(body)

HTTP Basic authorization header value for client credentials

Description

HTTP Basic authorization header value for client credentials

Usage

.basic_auth(id, secret)

Poll the device-token endpoint until the user authorizes (or we time out)

Description

Poll the device-token endpoint until the user authorizes (or we time out)

Usage

.codex_device_poll(client, device, timeout = 600, sleep = Sys.sleep,
                   post = .codex_post_json)

Arguments

sleep

Sleep function, injectable for testing.

post

JSON-POST function, injectable for testing.


Start the device-authorization flow: get a user code to display

Description

Start the device-authorization flow: get a user code to display

Usage

.codex_device_start(client, post = .codex_post_json)

Arguments

post

JSON-POST function, injectable for testing.


Exchange the device authorization code (with its PKCE verifier) for a token

Description

Exchange the device authorization code (with its PKCE verifier) for a token

Usage

.codex_exchange(client, code, verifier)

Attach the ChatGPT account id (from the access-token JWT) to a token

Description

Attach the ChatGPT account id (from the access-token JWT) to a token

Usage

.codex_finalize(token)

Run the device-login flow end to end (display code, wait, exchange)

Description

Run the device-login flow end to end (display code, wait, exchange)

Usage

.codex_login(client, open_url = interactive(), timeout = 600)

Classify a device-token poll response

Description

Returns one of "ok" (authorization granted), "pending" (keep waiting), "slow_down" (back off), or "error" (give up).

Usage

.codex_poll_classify(status, body)

POST a JSON body and parse the JSON response

Description

The device endpoints take JSON (the token endpoint takes form-encoding, which is what [.token_request] handles).

Usage

.codex_post_json(url, body)

Drop NULL list elements

Description

Drop NULL list elements

Usage

.drop_null(x)

Fetch with a few retries on transport errors / 5xx

Description

Fetch with a few retries on transport errors / 5xx

Usage

.fetch_retry(url, handle, times = 3L)

Form-encode a named list as application/x-www-form-urlencoded, dropping NULLs

Description

Form-encode a named list as application/x-www-form-urlencoded, dropping NULLs

Usage

.form_encode(fields)

Catch a single OAuth redirect on a loopback port

Description

Opens a one-shot serverSocket() listener, accepts the browser redirect, replies with a small page, and returns the parsed query.

Usage

.listen_for_redirect(port = 1410L, timeout = 120)

Arguments

port

Loopback port to listen on (must match the redirect URI).

timeout

Seconds to wait for the redirect.

Value

Named list of query parameters from the redirect.


Is this a session where a loopback OAuth listener cannot catch the redirect?

Description

TRUE for SSH sessions, RStudio Server, and headless unix (no X or Wayland display): in all of these the browser runs on a different machine, so the redirect to a 127.0.0.1 listener never arrives and the listener would hang. [oauth_token_authcode] uses this to default to the manual paste flow. macOS desktops have no DISPLAY but a working browser, so they are not treated as headless.

Usage

.oauth_no_loopback()

Parse a pasted redirect URL (or bare code) into query parameters

Description

Accepts the full http://127.0.0.1:.../?code=...&state=... address the browser landed on, a bare code=...&state=... query string, or just the code value on its own. Used by the manual = TRUE (no-listener) path.

Usage

.parse_redirect_input(x)

Parse the query parameters from an HTTP request line

Description

Parse the query parameters from an HTTP request line

Usage

.parse_request_query(req_line)

POST a grant to the token endpoint and parse the result

Description

POST a grant to the token endpoint and parse the result

Usage

.token_request(client, fields)

Build an authorization URL

Description

Build an authorization URL

Usage

oauth_authorize_url(client, scope = NULL, state = NULL)

Arguments

client

A [oauth_client] with an auth_url.

scope

Optional space-delimited scope string.

state

Optional opaque state for CSRF protection.

Value

The authorization URL to open in a browser.

Examples

oauth_authorize_url(
  oauth_client("id", token_url = "https://x/token",
               auth_url = "https://x/authorize"),
  scope = "user-read-email")

Authorization header value for a token

Description

Authorization header value for a token

Usage

oauth_bearer(token)

Arguments

token

A tinyoauth_token, a (legacy) httr Token2.0, or a raw access-token string.

Value

A string like "Bearer abc123" for use as an HTTP Authorization header.

Examples

tok <- structure(list(access_token = "abc123"), class = "tinyoauth_token")
oauth_bearer(tok)
# pass it to a request, e.g.:
#   curl::handle_setheaders(curl::new_handle(),
#                           Authorization = oauth_bearer(tok))

Default on-disk cache path for a client's token

Description

Default on-disk cache path for a client's token

Usage

oauth_cache_path(client)

Arguments

client

A [oauth_client].

Value

Path to the token cache file under tools::R_user_dir.

Examples

client <- oauth_client("my_app", token_url = "https://example.com/token")
oauth_cache_path(client)

Define an OAuth 2.0 client

Description

Define an OAuth 2.0 client

Usage

oauth_client(id, secret = NULL, token_url, auth_url = NULL,
             redirect_uri = "http://127.0.0.1:1410/")

Arguments

id

Client (application) id.

secret

Client secret, or NULL for public clients.

token_url

The provider's token endpoint.

auth_url

The provider's authorization endpoint (needed for the authorization-code grant; omit for client-credentials only).

redirect_uri

Redirect URI registered with the provider. Use a loopback IP literal over http (127.0.0.1); many providers reject localhost.

Value

A tinyoauth_client object.

Examples

spotify <- oauth_client(
  id = "your_id", secret = "your_secret",
  token_url = "https://accounts.spotify.com/api/token",
  auth_url  = "https://accounts.spotify.com/authorize")

Exchange an authorization code for a token

Description

Exchange an authorization code for a token

Usage

oauth_exchange_code(client, code)

Arguments

client

A [oauth_client].

code

The authorization code from the redirect.

Value

A tinyoauth_token.

Examples

## Not run: 
# `code` is the value the provider redirects back after the user approves.
tok <- oauth_exchange_code(client, code)

## End(Not run)

Is a token expired?

Description

Is a token expired?

Usage

oauth_expired(token, leeway = 60)

Arguments

token

A tinyoauth_token.

leeway

Seconds of slack before the hard expiry (default 60).

Value

TRUE if expired (or within leeway of it); FALSE when there is no expiry recorded.

Examples

expired <- structure(list(expires_at = Sys.time() - 1),
                     class = "tinyoauth_token")
oauth_expired(expired)
fresh <- structure(list(expires_at = Sys.time() + 3600),
                   class = "tinyoauth_token")
oauth_expired(fresh)

Import an httr '.httr-oauth' cache into tinyoauth

Description

Reads a token cached by httr's oauth2.0_token() and returns a tinyoauth client and token built from it – the app credentials, endpoints, and (crucially) the refresh token. This lets a package migrating off httr reuse an existing authorization instead of forcing users to log in again.

Usage

oauth_import_httr(path = ".httr-oauth", which = 1L)

Arguments

path

Path to the httr cache (default ".httr-oauth").

which

Which cached token to import when the file holds several (1-based; default 1).

Details

The imported access token is marked expired, since httr's cached access token is usually stale: the durable credential is the refresh token. Pass the result to [oauth_refresh] or [oauth_token] to mint a fresh access token.

Value

A list with client (a [oauth_client]) and token (a tinyoauth_token).

Examples

## Not run: 
imported <- oauth_import_httr("~/project/.httr-oauth")
token <- oauth_refresh(imported$client, imported$token)

## End(Not run)

Decode a JWT payload

Description

Base64url-decodes the payload (middle) segment of a JSON Web Token and parses it as JSON. Does not verify the signature; use only on tokens you already trust (e.g. one the provider just issued you).

Usage

oauth_jwt_payload(x)

Arguments

x

A JWT string, or a tinyoauth_token (its access_token is used).

Value

The decoded payload as a named list, or NULL if x has no usable JWT.

Examples

# A toy token: header.payload.signature, payload = {"sub":"abc"}
payload <- jsonlite::base64_enc(charToRaw('{"sub":"abc"}'))
jwt <- paste("x", gsub("=", "", payload), "y", sep = ".")
oauth_jwt_payload(jwt)$sub

Refresh an access token

Description

Refresh an access token

Usage

oauth_refresh(client, token)

Arguments

client

A [oauth_client].

token

A tinyoauth_token carrying a refresh token.

Value

A refreshed tinyoauth_token. Providers that omit a new refresh token on refresh keep the existing one.

Examples

## Not run: 
tok <- oauth_refresh(spotify, tok)

## End(Not run)

Make an authenticated request

Description

Sends an HTTP request with the token as a Bearer header, retrying transient failures, and parses a JSON response. A convenience over building a curl handle by hand; for anything exotic, use [oauth_bearer] with curl directly.

Usage

oauth_request(token, url, method = "GET", query = NULL, body = NULL,
              headers = NULL, flatten = FALSE, retries = 3L)

Arguments

token

A tinyoauth_token, a (legacy) httr token, or a raw access-token string.

url

Endpoint URL.

method

HTTP method (default "GET").

query

Optional named list of query parameters.

body

Optional R object sent as a JSON body.

headers

Optional named character vector of extra headers.

flatten

Passed to jsonlite::fromJSON (default FALSE).

retries

Attempts on transport errors / HTTP 5xx (default 3).

Value

Parsed JSON, or invisibly NULL for an empty response body. Non-2xx responses raise an error carrying the status and body.

Examples

## Not run: 
oauth_request(tok, "https://api.spotify.com/v1/me")

## End(Not run)

Get a valid token, using the cache and refreshing as needed

Description

Returns a cached token if still valid; refreshes it if expired and a refresh token is available; otherwise runs the authorization-code flow. The result is written back to cache.

Usage

oauth_token(client, scope = NULL, cache = oauth_cache_path(client), ...)

Arguments

client

A [oauth_client].

scope

Optional space-delimited scope string (for first authorization).

cache

Cache file path, or NULL to disable caching. Defaults to [oauth_cache_path].

...

Passed to [oauth_token_authcode] (e.g. port, open_browser).

Value

A valid tinyoauth_token.

Examples

## Not run: 
tok <- oauth_token(spotify, scope = "user-read-email")

## End(Not run)

Run the authorization-code flow end to end

Description

Prints (and optionally opens) the authorization URL, then obtains the redirect either by catching it on a loopback listener (default) or, with manual = TRUE, by having you paste the redirected URL back. After verifying state, it exchanges the code.

Usage

oauth_token_authcode(client, scope = NULL, port = 1410L,
                     open_browser = interactive(), timeout = 120, manual = NA)

Arguments

client

A [oauth_client] with an auth_url.

scope

Optional space-delimited scope string.

port

Loopback port for the listener; must match the port in client$redirect_uri (default 1410).

open_browser

Open the URL automatically (default: interactive only).

timeout

Seconds to wait for the redirect.

manual

Skip the loopback listener and read the redirected address (or bare code) from the console instead. The default (NA) auto-detects: it switches to manual on a remote/headless session (SSH, RStudio Server, or unix with no display), where the browser runs elsewhere and the redirect can never reach a local listener (so the listener would just hang). Pass TRUE/FALSE to force it. In manual mode the browser shows a "can't reach 127.0.0.1" page after you approve – that is expected; copy its address bar and paste it.

Value

A tinyoauth_token (with a refresh token, when the provider issues one).

Examples

## Not run: 
tok <- oauth_token_authcode(spotify, scope = "user-read-email")
tok <- oauth_token_authcode(google, manual = TRUE)  # force manual paste

## End(Not run)

Fetch a token via the client-credentials grant

Description

App-only access (no user context).

Usage

oauth_token_client(client)

Arguments

client

A [oauth_client].

Value

A tinyoauth_token.

Examples

## Not run: 
tok <- oauth_token_client(spotify)

## End(Not run)

Get a valid OpenAI Codex token, using the cache and refreshing as needed

Description

The Codex analogue of [oauth_token]: returns a cached token if still valid, refreshes it if expired and a refresh token is available, otherwise runs the device-login flow. The token carries an extra account_id field (the ChatGPT account id) and is written back to cache.

Usage

oauth_token_openai_codex(cache = oauth_cache_path(openai_codex_client()),
                         open_url = interactive(), timeout = 600, login = TRUE)

Arguments

cache

Cache file path, or NULL to disable caching. Defaults to [oauth_cache_path] for the Codex client.

open_url

Open the verification URL automatically (default: interactive sessions only).

timeout

Seconds to wait for device authorization (default 600).

login

Run the device-login flow when no usable cached/refreshable token exists (default TRUE). Pass FALSE to get the cached (and refreshed-if-needed) token or NULL, without ever prompting – useful inside a request path where an interactive login would be wrong.

Value

A tinyoauth_token with access_token, refresh_token, expires_at, and account_id; or NULL when login is FALSE and no usable token is cached.

Examples

## Not run: 
tok <- oauth_token_openai_codex()
curl::handle_setheaders(curl::new_handle(),
                        Authorization = oauth_bearer(tok),
                        "chatgpt-account-id" = tok$account_id)

## End(Not run)

Extract the ChatGPT account id from a Codex token

Description

Reads the chatgpt_account_id claim that OpenAI nests under https://api.openai.com/auth in the access-token JWT.

Usage

openai_codex_account_id(token)

Arguments

token

A tinyoauth_token (or raw access-token string).

Value

The account id string, or NULL if absent.

Examples

# A token whose access-token JWT carries the account-id claim:
claim <- jsonlite::toJSON(
  list("https://api.openai.com/auth" = list(chatgpt_account_id = "acct_123")),
  auto_unbox = TRUE)
b64url <- function(s) {
  chartr("+/", "-_", gsub("[\n=]", "", jsonlite::base64_enc(charToRaw(s))))
}
jwt <- paste("header", b64url(claim), "signature", sep = ".")
openai_codex_account_id(jwt)

OAuth client for the OpenAI Codex (ChatGPT) device-login flow

Description

A preconfigured [oauth_client] for ChatGPT-subscription-backed Codex access, carrying OpenAI's device-authorization endpoints alongside the standard token endpoint. The client id is OpenAI's public native-app identifier, not a secret.

Usage

openai_codex_client()

Value

A tinyoauth_client with extra device_usercode_url, device_token_url, and verification_uri fields.

Examples

openai_codex_client()