diff --git a/DESCRIPTION b/DESCRIPTION index 4b79da0..68c25cb 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: vmxr Title: VeloMetrix R Client -Version: 0.1.1 +Version: 0.1.1.9000 Authors@R: c( person(given = "Eric", family = "Novik", email = "eric@generable.com", role = c("aut", "cre")), person(given = "Juho", family = "Timonen", role = "ctb"), @@ -23,6 +23,7 @@ Imports: cli, curl, httr2, + jsonlite, rlang, tibble, vctrs @@ -31,7 +32,6 @@ Suggests: dplyr, ggplot2, httptest2, - jsonlite, knitr, rmarkdown, testthat (>= 3.0.0), diff --git a/NAMESPACE b/NAMESPACE index 6b622aa..8aae9d3 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -33,6 +33,7 @@ export(vmx_fit_obs_vs_pred) export(vmx_fit_subject_estimates) export(vmx_fit_vpc) export(vmx_health) +export(vmx_login) export(vmx_model_build) export(vmx_model_build_artifacts) export(vmx_model_build_cancel) diff --git a/NEWS.md b/NEWS.md index 63cb0a2..a32141c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,16 @@ +# vmxr 0.1.1.9000 (development) + +* Native OIDC device-code authentication (GEN-2332). `vmx_login()` runs the + RFC 8628 device-code flow via `httr2::oauth_flow_device()` against the + Authentik provider (public client, `offline_access` scope for a refresh + token) and caches the token. `vmx_client()` now **auto-authenticates** from + that cache when no `VMX_API_TOKEN` / `token=` is set, refreshing silently and + prompting `vmx_login()` only when there's no usable cached token. The token + is cached as plain JSON at `~/.config/vmx/oidc-token.json` — the same path and + shape the `vmx` CLI uses (`0600`) — so one login serves both R and the CLI and + survives a fresh R session / pod restart. Configured via `VMX_OIDC_ISSUER`, + `VMX_OIDC_CLIENT_ID`, `VMX_OIDC_SCOPES`. + # vmxr 0.1.1 * Send upload `config_yaml` as inline YAML text, matching the current API form diff --git a/R/client.R b/R/client.R index 3661930..5207df5 100644 --- a/R/client.R +++ b/R/client.R @@ -7,9 +7,17 @@ #' -> a classed error. The token is stored on the object but redacted in #' [print()] and never logged. #' +#' When no token is supplied (neither `token=` nor `VMX_API_TOKEN`), the client +#' **auto-authenticates** via OIDC: it reads the token cached by [vmx_login()] +#' (`~/.config/vmx/oidc-token.json`), refreshing it silently when expired, and +#' -- in an interactive session -- running [vmx_login()] if there is no usable +#' cached token. This requires `VMX_OIDC_ISSUER` + `VMX_OIDC_CLIENT_ID` to be +#' set; otherwise a `vmx_auth_error` names both auth methods. +#' #' @param base_url API base URL. Defaults to `Sys.getenv("VMX_API_BASE_URL")`. #' @param token Authentik personal access token (PAT). Defaults to -#' `Sys.getenv("VMX_API_TOKEN")`. Never hard-code a PAT in source. +#' `Sys.getenv("VMX_API_TOKEN")`, then to an OIDC access token (see +#' [vmx_login()]). Never hard-code a PAT in source. #' @param ... Reserved for future options (timeouts, retries, user agent). #' #' @return An object of class `vmx_client`. @@ -25,10 +33,8 @@ vmx_client <- function(base_url = NULL, token = NULL, ...) { ) } if (!nzchar(token)) { - vmx_abort( - "No API token. Set `token=` or the VMX_API_TOKEN env var.", - class = "vmx_auth_error" - ) + # No PAT: fall back to OIDC device-code auth (cache -> refresh -> login). + token <- .vmx_client_bearer() } structure( diff --git a/R/oidc.R b/R/oidc.R new file mode 100644 index 0000000..90684e8 --- /dev/null +++ b/R/oidc.R @@ -0,0 +1,415 @@ +# Native OIDC device-code authentication (GEN-2332). +# +# vmxr authenticates to vmx-api entirely in R via the OIDC device-code flow +# (RFC 8628, run by httr2::oauth_flow_device) against the staging Authentik +# vmx-cli provider -- no Python CLI dependency. `vmx_login()` runs the flow and +# caches the token; `vmx_client()` auto-authenticates from that cache, silently +# refreshing with the refresh token, and prompting a fresh login only when there +# is no usable cached token. +# +# The on-disk cache is a plain JSON file with the *exact same shape* the vmx-cli +# writes (clients/cli/src/vmx_cli/oidc.py: TokenSet.to_json), stored at the CLI's +# path `~/.config/vmx/oidc-token.json` on the persistent home PVC. That makes one +# `vmx_login()` serve both R and the terminal CLI, and lets the token survive a +# fresh R session / pod restart. Config comes from the same env vars the CLI +# reads: VMX_OIDC_ISSUER / VMX_OIDC_CLIENT_ID / VMX_OIDC_SCOPES. + +# Default scopes. `offline_access` is required to be issued a refresh token +# (Authentik grants it natively even though it is absent from discovery +# `scopes_supported` -- confirmed by the GEN-2330 spike). `goauthentik.io/api` +# is required or vmx-api's Bearer shim (auth.py:_from_bearer -> Authentik +# /api/v3/core/users/me/) 401s on every call. +.vmx_oidc_default_scopes <- "openid profile email offline_access goauthentik.io/api" + +# The CLI's cache path (sibling of the CLI login-config under ~/.config/vmx). +.vmx_oidc_default_cache <- "~/.config/vmx/oidc-token.json" + +# Re-mint a cached access token this many seconds before its real expiry, so a +# token is never used past its life mid-request (matches the CLI's skew). +.vmx_oidc_expiry_skew <- 60 + +.vmx_oidc_user_agent <- "vmxr (https://github.com/generable/vmxr)" + +# -- config ------------------------------------------------------------------- + +#' Resolve OIDC client config from args / env vars +#' +#' Precedence per field: explicit arg -> env var -> (scopes only) the default +#' scopes. The issuer is stripped of trailing slashes so it matches the CLI's +#' `issuer.rstrip('/')` in the shared cache. Missing issuer / client_id is a +#' usage error. +#' @keywords internal +#' @noRd +vmx_oidc_config <- function(issuer = NULL, client_id = NULL, scopes = NULL) { + issuer <- trimws(issuer %||% Sys.getenv("VMX_OIDC_ISSUER", unset = "")) + client_id <- trimws(client_id %||% Sys.getenv("VMX_OIDC_CLIENT_ID", unset = "")) + scopes <- trimws(scopes %||% Sys.getenv("VMX_OIDC_SCOPES", unset = "")) + if (!nzchar(scopes)) scopes <- .vmx_oidc_default_scopes + + if (!nzchar(issuer)) { + vmx_abort( + "No OIDC issuer. Set `issuer=` or the VMX_OIDC_ISSUER env var.", + class = "vmx_usage_error" + ) + } + if (!nzchar(client_id)) { + vmx_abort( + "No OIDC client id. Set `client_id=` or the VMX_OIDC_CLIENT_ID env var.", + class = "vmx_usage_error" + ) + } + + list(issuer = sub("/+$", "", issuer), client_id = client_id, scopes = scopes) +} + +# TRUE when both required OIDC env vars are set (so vmx_client() can decide +# whether an auto-auth attempt is even possible). +.vmx_oidc_configured <- function() { + nzchar(trimws(Sys.getenv("VMX_OIDC_ISSUER", unset = ""))) && + nzchar(trimws(Sys.getenv("VMX_OIDC_CLIENT_ID", unset = ""))) +} + +# -- token value -------------------------------------------------------------- + +# A cached token: a plain list mirroring the CLI's on-disk fields. `expires_at` +# is an absolute epoch-seconds deadline; `refresh_token` is NULL when absent. +.vmx_token <- function(access_token, refresh_token, expires_at, token_type, + issuer, client_id) { + list( + access_token = access_token, + refresh_token = if (is.null(refresh_token) || !nzchar(refresh_token)) NULL else refresh_token, + expires_at = as.numeric(expires_at), + token_type = token_type %||% "Bearer", + issuer = sub("/+$", "", issuer), + client_id = client_id + ) +} + +.vmx_token_expired <- function(token, now = as.numeric(Sys.time()), + skew = .vmx_oidc_expiry_skew) { + now >= (token$expires_at - skew) +} + +.vmx_token_matches <- function(token, config) { + identical(token$issuer, sub("/+$", "", config$issuer)) && + identical(token$client_id, config$client_id) +} + +# Build a token from a raw OAuth token-endpoint response body. +.vmx_token_from_body <- function(config, body) { + access <- body$access_token + if (!is.character(access) || !length(access) || !nzchar(access)) { + vmx_abort("OIDC token response did not contain an access_token.", class = "vmx_auth_error") + } + ttl <- suppressWarnings(as.numeric(body$expires_in %||% 300)) + if (is.na(ttl)) ttl <- 300 + .vmx_token( + access_token = access, + refresh_token = body$refresh_token, + expires_at = as.numeric(Sys.time()) + ttl, + token_type = body$token_type %||% "Bearer", + issuer = config$issuer, + client_id = config$client_id + ) +} + +# Build a token from the httr2_token object oauth_flow_device() returns. +.vmx_token_from_httr2 <- function(config, token) { + access <- token$access_token + if (is.null(access) || !nzchar(access)) { + vmx_abort("OIDC device-code flow returned no access token.", class = "vmx_auth_error") + } + expires_at <- token$expires_at + if (is.null(expires_at)) { + issued <- as.numeric(token$.date %||% Sys.time()) + ttl <- suppressWarnings(as.numeric(token$expires_in %||% 300)) + if (is.na(ttl)) ttl <- 300 + expires_at <- issued + ttl + } + .vmx_token( + access_token = access, + refresh_token = token$refresh_token, + expires_at = expires_at, + token_type = token$token_type %||% "Bearer", + issuer = config$issuer, + client_id = config$client_id + ) +} + +# -- on-disk cache (CLI-compatible JSON, 0600) -------------------------------- + +.vmx_oidc_cache_path <- function() { + path <- trimws(Sys.getenv("VMX_OIDC_TOKEN_CACHE", unset = "")) + if (!nzchar(path)) path <- .vmx_oidc_default_cache + path.expand(path) +} + +# Serialize to the CLI's JSON shape (missing refresh token -> explicit null). +.vmx_token_json <- function(token) { + jsonlite::toJSON( + list( + access_token = token$access_token, + refresh_token = token$refresh_token %||% NA, + expires_at = token$expires_at, + token_type = token$token_type, + issuer = token$issuer, + client_id = token$client_id + ), + auto_unbox = TRUE, pretty = TRUE, null = "null", na = "null" + ) +} + +.vmx_token_from_json <- function(data) { + need <- c("access_token", "expires_at", "issuer", "client_id") + if (!all(need %in% names(data))) stop("incomplete token cache", call. = FALSE) + .vmx_token( + access_token = as.character(data[["access_token"]]), + refresh_token = { + rt <- data[["refresh_token"]] + if (is.null(rt) || !length(rt) || is.na(rt[1])) NULL else as.character(rt[1]) + }, + expires_at = as.numeric(data[["expires_at"]]), + token_type = as.character(data[["token_type"]] %||% "Bearer"), + issuer = as.character(data[["issuer"]]), + client_id = as.character(data[["client_id"]]) + ) +} + +# Load the cached token, or NULL if absent/corrupt. A corrupt cache is treated +# as "no token" (re-login), not a hard error -- it is operator-local state. +.vmx_load_cached_token <- function(path = .vmx_oidc_cache_path()) { + if (!file.exists(path)) return(NULL) + tryCatch( + .vmx_token_from_json(jsonlite::fromJSON(path, simplifyVector = TRUE)), + error = function(e) NULL + ) +} + +# Persist the token with owner-only (0600) perms. The cache holds a bearer + +# refresh token, so it must never be group/world readable. Tighten the umask to +# 0177 for the write so the file is 0600 *from birth* (no umask-default window), +# write to an unpredictable temp name in the same dir (no fixed-name symlink +# target; same filesystem -> atomic rename), then replace the target. +.vmx_save_cached_token <- function(token, path = .vmx_oidc_cache_path()) { + dir <- dirname(path) + if (!dir.exists(dir)) dir.create(dir, recursive = TRUE, showWarnings = FALSE, mode = "0700") + old_umask <- Sys.umask("0177") + on.exit(Sys.umask(old_umask), add = TRUE) + tmp <- tempfile(tmpdir = dir, fileext = ".tmp") + on.exit(if (file.exists(tmp)) unlink(tmp), add = TRUE) + writeLines(.vmx_token_json(token), tmp) + Sys.chmod(tmp, mode = "0600", use_umask = FALSE) + file.rename(tmp, path) + Sys.chmod(path, mode = "0600", use_umask = FALSE) + invisible(path) +} + +# -- provider I/O (discovery / device flow / refresh) ------------------------- + +.vmx_oidc_perform <- function(req, what) { + tryCatch( + httr2::req_perform(req), + error = function(e) { + vmx_abort( + sprintf("OIDC %s request failed: %s", what, conditionMessage(e)), + class = "vmx_auth_error", parent = e + ) + } + ) +} + +.vmx_oidc_error_detail <- function(resp) { + body <- tryCatch(httr2::resp_body_json(resp, simplifyVector = FALSE), error = function(e) NULL) + if (is.list(body)) { + err <- body$error + desc <- body$error_description + if (!is.null(err) && !is.null(desc)) return(paste0(err, ": ", desc)) + if (!is.null(err)) return(as.character(err)) + } + httr2::resp_status_desc(resp) +} + +# Resolve device / token endpoints from the issuer's discovery document rather +# than hard-coding them (a provider-path change in Authentik then can't silently +# break vmxr). Matches the CLI's discovery. +.vmx_oidc_discover <- function(config) { + url <- paste0(config$issuer, "/.well-known/openid-configuration") + req <- httr2::request(url) |> + httr2::req_user_agent(.vmx_oidc_user_agent) |> + httr2::req_error(is_error = function(resp) FALSE) + resp <- .vmx_oidc_perform(req, "discovery") + if (httr2::resp_status(resp) >= 400) { + vmx_abort( + sprintf("OIDC discovery failed (HTTP %d) at %s", httr2::resp_status(resp), url), + class = "vmx_auth_error" + ) + } + body <- httr2::resp_body_json(resp, simplifyVector = FALSE) + list( + device = .vmx_oidc_require_endpoint(body, "device_authorization_endpoint", "device-authorization endpoint"), + token = .vmx_oidc_require_endpoint(body, "token_endpoint", "token endpoint") + ) +} + +.vmx_oidc_require_endpoint <- function(discovery, key, what) { + endpoint <- discovery[[key]] + if (!is.character(endpoint) || length(endpoint) != 1L || !nzchar(endpoint)) { + vmx_abort( + sprintf( + "The OIDC provider does not advertise a %s ('%s' missing from discovery).", + what, key + ), + class = "vmx_auth_error" + ) + } + endpoint +} + +# A public OAuth client: client_id in the request body, no secret, no PKCE. +.vmx_oidc_client <- function(config, token_endpoint) { + httr2::oauth_client(id = config$client_id, token_url = token_endpoint, auth = "body") +} + +# Wrap httr2's RFC 8628 device-code flow in a mockable binding. Kept minimal so +# tests can stub it (the real flow opens a browser and blocks on polling). +.vmx_oidc_device_flow <- function(oauth_client, device_url, scopes) { + httr2::oauth_flow_device(oauth_client, auth_url = device_url, scope = scopes) +} + +# Exchange a refresh token for a fresh access token (no browser). Authentik may +# omit a new refresh token on refresh (non-rotating), so the caller keeps the +# prior one. +.vmx_oidc_refresh <- function(config, token_endpoint, refresh_token) { + req <- httr2::request(token_endpoint) |> + httr2::req_user_agent(.vmx_oidc_user_agent) |> + httr2::req_error(is_error = function(resp) FALSE) |> + httr2::req_body_form( + grant_type = "refresh_token", + refresh_token = refresh_token, + client_id = config$client_id, + scope = config$scopes + ) + resp <- .vmx_oidc_perform(req, "refresh") + if (httr2::resp_status(resp) >= 400) { + vmx_abort( + paste0("OIDC refresh token was rejected: ", .vmx_oidc_error_detail(resp)), + class = "vmx_auth_error" + ) + } + .vmx_token_from_body(config, httr2::resp_body_json(resp, simplifyVector = FALSE)) +} + +.vmx_oidc_refresh_flow <- function(config, token) { + endpoints <- .vmx_oidc_discover(config) + refreshed <- .vmx_oidc_refresh(config, endpoints$token, token$refresh_token) + if (is.null(refreshed$refresh_token)) refreshed$refresh_token <- token$refresh_token + refreshed +} + +# -- public entry point + client integration ---------------------------------- + +#' Authenticate to VeloMetrix via OIDC device-code +#' +#' Runs the OIDC device-code flow (RFC 8628, via [httr2::oauth_flow_device()]) +#' against the workspace's Authentik provider and caches the resulting token so +#' later [vmx_client()] calls authenticate automatically. Endpoints are resolved +#' from the issuer's `.well-known/openid-configuration` discovery document; the +#' flow uses a **public client** (no secret, no PKCE) and requests +#' `offline_access` so Authentik issues a **refresh token**. +#' +#' The token is written as plain JSON to `~/.config/vmx/oidc-token.json` (the +#' same path and shape the `vmx` CLI uses, with `0600` permissions), so one +#' `vmx_login()` serves both R and the terminal CLI and the session survives a +#' fresh R process or workspace pod restart. Because the refresh token is +#' persisted on the home PVC, you log in **once per refresh-token lifetime** +#' (~30 days). +#' +#' Configuration is read from environment variables (matching the CLI): +#' `VMX_OIDC_ISSUER`, `VMX_OIDC_CLIENT_ID`, and optionally `VMX_OIDC_SCOPES`. +#' Confirmed-working staging values: issuer +#' `https://auth.staging.gnrbl.co/application/o/generable-staging-vmx-cli/`, +#' client id `generable-staging-vmx-cli`. +#' +#' @param issuer OIDC issuer base URL. Defaults to `VMX_OIDC_ISSUER`. +#' @param client_id Public OIDC client id. Defaults to `VMX_OIDC_CLIENT_ID`. +#' @param scopes Space-separated scopes. Defaults to `VMX_OIDC_SCOPES`, then to +#' `"openid profile email offline_access goauthentik.io/api"`. +#' @param cache_path Where to cache the token. Defaults to +#' `VMX_OIDC_TOKEN_CACHE`, then `~/.config/vmx/oidc-token.json`. +#' +#' @return Invisibly, the cached token (a list; the access/refresh tokens are +#' secret and never printed). +#' @export +vmx_login <- function(issuer = NULL, client_id = NULL, scopes = NULL, + cache_path = NULL) { + config <- vmx_oidc_config(issuer, client_id, scopes) + cache_path <- cache_path %||% .vmx_oidc_cache_path() + endpoints <- .vmx_oidc_discover(config) + oauth_client <- .vmx_oidc_client(config, endpoints$token) + raw <- .vmx_oidc_device_flow(oauth_client, endpoints$device, config$scopes) + token <- .vmx_token_from_httr2(config, raw) + .vmx_save_cached_token(token, cache_path) + cli::cli_alert_success("Logged in to VeloMetrix; token cached at {.path {cache_path}}.") + invisible(token) +} + +#' Resolve an access token for a client from the OIDC cache +#' +#' Returns a usable access token, refreshing silently when the cached one is +#' expired, and running [vmx_login()] when there is no usable cached token (only +#' possible in an interactive session; otherwise a `vmx_auth_error` is raised). +#' @keywords internal +#' @noRd +vmx_oidc_access_token <- function(config = vmx_oidc_config(), + cache_path = .vmx_oidc_cache_path(), + can_prompt = interactive()) { + token <- .vmx_load_cached_token(cache_path) + refresh_err <- NULL + if (!is.null(token) && .vmx_token_matches(token, config)) { + if (!.vmx_token_expired(token)) return(token$access_token) + if (!is.null(token$refresh_token)) { + refreshed <- tryCatch( + .vmx_oidc_refresh_flow(config, token), + error = function(e) { + refresh_err <<- conditionMessage(e) + NULL + } + ) + if (!is.null(refreshed)) { + .vmx_save_cached_token(refreshed, cache_path) + return(refreshed$access_token) + } + } + } + if (!isTRUE(can_prompt)) { + msg <- paste0( + "No usable cached OIDC token and not in an interactive session. Run ", + "`vmx_login()` interactively (or set `token=` / VMX_API_TOKEN)." + ) + # Surface *why* a silent refresh failed (revoked/expired refresh token, + # network) instead of collapsing every cause into the generic message. + if (!is.null(refresh_err)) msg <- paste0(msg, " (token refresh failed: ", refresh_err, ")") + vmx_abort(msg, class = "vmx_auth_error") + } + token <- vmx_login( + issuer = config$issuer, client_id = config$client_id, + scopes = config$scopes, cache_path = cache_path + ) + token$access_token +} + +# Resolve a bearer token for vmx_client() when no PAT is configured. Attempts +# OIDC auto-auth when the OIDC env vars are set; otherwise raises a single +# vmx_auth_error naming both auth methods. +.vmx_client_bearer <- function() { + if (!.vmx_oidc_configured()) { + vmx_abort( + paste0( + "No API token. Set `token=` / VMX_API_TOKEN (an Authentik PAT), or ", + "configure OIDC (VMX_OIDC_ISSUER + VMX_OIDC_CLIENT_ID) and run `vmx_login()`." + ), + class = "vmx_auth_error" + ) + } + vmx_oidc_access_token() +} diff --git a/README.md b/README.md index d52ebb9..7a757d1 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,32 @@ nca <- vmx_nca(dv, time_basis = "observed") vmx_nca_result(nca) ``` +### Sign in with OIDC (no PAT) + +Instead of a standing PAT you can authenticate with the OIDC **device-code** +flow, entirely in R (no Python CLI needed). Configure the provider via +environment variables — the same ones the `vmx` CLI reads — and call +`vmx_login()`: + +```r +# ~/.Renviron (or the workspace image sets these for you) +# VMX_API_BASE_URL = https://vmx-api.staging.gnrbl.co +# VMX_OIDC_ISSUER = https://auth.staging.gnrbl.co/application/o/generable-staging-vmx-cli/ +# VMX_OIDC_CLIENT_ID = generable-staging-vmx-cli + +library(vmxr) +vmx_login() # opens the approve page in a browser; approve once +vmx_whoami() # vmx_client() now auto-authenticates from the cached token +``` + +`vmx_login()` caches the token as plain JSON at `~/.config/vmx/oidc-token.json` +(the CLI's path and shape, `0600`), so **one login serves both R and the +terminal CLI**. The refresh token is persisted on your home directory, so the +access token is refreshed silently and the login **survives R / workspace +restarts** — you sign in roughly once a month. When no `VMX_API_TOKEN` is set, +`vmx_client()` (and every verb that builds one) authenticates from this cache +automatically, prompting `vmx_login()` only when there's no usable cached token. + ## Design The package exposes a curated, hand-written public API in `R/*.R` that adds diff --git a/man/vmx_client.Rd b/man/vmx_client.Rd index 5c53150..779ab5a 100644 --- a/man/vmx_client.Rd +++ b/man/vmx_client.Rd @@ -10,7 +10,8 @@ vmx_client(base_url = NULL, token = NULL, ...) \item{base_url}{API base URL. Defaults to \code{Sys.getenv("VMX_API_BASE_URL")}.} \item{token}{Authentik personal access token (PAT). Defaults to -\code{Sys.getenv("VMX_API_TOKEN")}. Never hard-code a PAT in source.} +\code{Sys.getenv("VMX_API_TOKEN")}, then to an OIDC access token (see +\code{\link[=vmx_login]{vmx_login()}}). Never hard-code a PAT in source.} \item{...}{Reserved for future options (timeouts, retries, user agent).} } @@ -23,3 +24,11 @@ Resolves connection config with the precedence: explicit args -> -> a classed error. The token is stored on the object but redacted in \code{\link[=print]{print()}} and never logged. } +\details{ +When no token is supplied (neither \code{token=} nor \code{VMX_API_TOKEN}), the client +\strong{auto-authenticates} via OIDC: it reads the token cached by \code{\link[=vmx_login]{vmx_login()}} +(\verb{~/.config/vmx/oidc-token.json}), refreshing it silently when expired, and +-- in an interactive session -- running \code{\link[=vmx_login]{vmx_login()}} if there is no usable +cached token. This requires \code{VMX_OIDC_ISSUER} + \code{VMX_OIDC_CLIENT_ID} to be +set; otherwise a \code{vmx_auth_error} names both auth methods. +} diff --git a/man/vmx_login.Rd b/man/vmx_login.Rd new file mode 100644 index 0000000..50e3dbc --- /dev/null +++ b/man/vmx_login.Rd @@ -0,0 +1,45 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oidc.R +\name{vmx_login} +\alias{vmx_login} +\title{Authenticate to VeloMetrix via OIDC device-code} +\usage{ +vmx_login(issuer = NULL, client_id = NULL, scopes = NULL, cache_path = NULL) +} +\arguments{ +\item{issuer}{OIDC issuer base URL. Defaults to \code{VMX_OIDC_ISSUER}.} + +\item{client_id}{Public OIDC client id. Defaults to \code{VMX_OIDC_CLIENT_ID}.} + +\item{scopes}{Space-separated scopes. Defaults to \code{VMX_OIDC_SCOPES}, then to +\code{"openid profile email offline_access goauthentik.io/api"}.} + +\item{cache_path}{Where to cache the token. Defaults to +\code{VMX_OIDC_TOKEN_CACHE}, then \verb{~/.config/vmx/oidc-token.json}.} +} +\value{ +Invisibly, the cached token (a list; the access/refresh tokens are +secret and never printed). +} +\description{ +Runs the OIDC device-code flow (RFC 8628, via \code{\link[httr2:oauth_flow_device]{httr2::oauth_flow_device()}}) +against the workspace's Authentik provider and caches the resulting token so +later \code{\link[=vmx_client]{vmx_client()}} calls authenticate automatically. Endpoints are resolved +from the issuer's \code{.well-known/openid-configuration} discovery document; the +flow uses a \strong{public client} (no secret, no PKCE) and requests +\code{offline_access} so Authentik issues a \strong{refresh token}. +} +\details{ +The token is written as plain JSON to \verb{~/.config/vmx/oidc-token.json} (the +same path and shape the \code{vmx} CLI uses, with \code{0600} permissions), so one +\code{vmx_login()} serves both R and the terminal CLI and the session survives a +fresh R process or workspace pod restart. Because the refresh token is +persisted on the home PVC, you log in \strong{once per refresh-token lifetime} +(~30 days). + +Configuration is read from environment variables (matching the CLI): +\code{VMX_OIDC_ISSUER}, \code{VMX_OIDC_CLIENT_ID}, and optionally \code{VMX_OIDC_SCOPES}. +Confirmed-working staging values: issuer +\code{https://auth.staging.gnrbl.co/application/o/generable-staging-vmx-cli/}, +client id \code{generable-staging-vmx-cli}. +} diff --git a/tests/testthat/test-oidc.R b/tests/testthat/test-oidc.R new file mode 100644 index 0000000..8690834 --- /dev/null +++ b/tests/testthat/test-oidc.R @@ -0,0 +1,168 @@ +# Unit tests for native OIDC device-code auth (GEN-2332). The device-code flow +# and provider I/O are stubbed (mocked bindings + httr2 response mocks) so no +# live Authentik / browser is needed. + +issuer <- "https://auth.test/application/o/vmx-cli/" +stripped_issuer <- "https://auth.test/application/o/vmx-cli" + +local_oidc_env <- function(cache, env = parent.frame()) { + withr::local_envvar( + .new = list( + VMX_OIDC_ISSUER = issuer, + VMX_OIDC_CLIENT_ID = "test-cli", + VMX_OIDC_SCOPES = "", + VMX_OIDC_TOKEN_CACHE = cache, + VMX_API_TOKEN = "", + VMX_API_BASE_URL = "https://vmx.test" + ), + .local_envir = env + ) +} + +test_that("vmx_login runs the device flow and caches a CLI-shaped token 0600", { + cache <- withr::local_tempfile(fileext = ".json") + local_oidc_env(cache) + testthat::local_mocked_bindings( + .vmx_oidc_discover = function(config) list(device = "https://auth.test/device", token = "https://auth.test/token"), + .vmx_oidc_device_flow = function(oauth_client, device_url, scopes) { + list(access_token = "acc_123", refresh_token = "ref_456", expires_in = 600, token_type = "Bearer") + } + ) + + tok <- vmx_login() + expect_equal(tok$access_token, "acc_123") + expect_equal(tok$refresh_token, "ref_456") + expect_equal(tok$issuer, stripped_issuer) # trailing slash stripped, matches CLI + expect_true(file.exists(cache)) + + # On-disk shape matches the vmx-cli cache (clients/cli/.../oidc.py TokenSet). + on_disk <- jsonlite::fromJSON(cache, simplifyVector = TRUE) + expect_setequal( + names(on_disk), + c("access_token", "refresh_token", "expires_at", "token_type", "issuer", "client_id") + ) + expect_equal(on_disk$client_id, "test-cli") + + # Secret cache must be owner-only. POSIX file modes only exist on unix; on + # Windows file.info()$mode reflects the read-only bit, not 0600, and the + # deployment target (the Linux home PVC) is unix, so scope the assertion. + if (.Platform$OS.type != "windows") { + expect_equal(as.character(file.info(cache)$mode), "600") + } +}) + +test_that("default scopes request offline_access (needed for a refresh token)", { + withr::local_envvar(VMX_OIDC_ISSUER = issuer, VMX_OIDC_CLIENT_ID = "test-cli", VMX_OIDC_SCOPES = "") + cfg <- vmx_oidc_config() + expect_match(cfg$scopes, "offline_access") + expect_match(cfg$scopes, "goauthentik.io/api") +}) + +test_that("cache survives a fresh session / pod restart (round-trips through disk)", { + cache <- withr::local_tempfile(fileext = ".json") + token <- .vmx_token( + access_token = "acc", refresh_token = "ref", + expires_at = as.numeric(Sys.time()) + 600, + token_type = "Bearer", issuer = issuer, client_id = "test-cli" + ) + .vmx_save_cached_token(token, cache) + + # A brand-new reader (simulating a fresh R process) sees the same token. + reloaded <- .vmx_load_cached_token(cache) + expect_equal(reloaded$access_token, "acc") + expect_equal(reloaded$refresh_token, "ref") + expect_equal(reloaded$issuer, stripped_issuer) +}) + +test_that("a corrupt cache is treated as no token, not an error", { + cache <- withr::local_tempfile(fileext = ".json") + writeLines("{ not json", cache) + expect_null(.vmx_load_cached_token(cache)) +}) + +test_that("expired cached token is silently refreshed and re-cached", { + cache <- withr::local_tempfile(fileext = ".json") + local_oidc_env(cache) + expired <- .vmx_token( + access_token = "old_acc", refresh_token = "ref_old", + expires_at = as.numeric(Sys.time()) - 10, # already expired + token_type = "Bearer", issuer = issuer, client_id = "test-cli" + ) + .vmx_save_cached_token(expired, cache) + + testthat::local_mocked_bindings( + .vmx_oidc_discover = function(config) list(device = "https://auth.test/device", token = "https://auth.test/token") + ) + httr2::local_mocked_responses(list( + httr2::response_json(body = list( + access_token = "new_acc", refresh_token = "ref_new", expires_in = 600, token_type = "Bearer" + )) + )) + + access <- vmx_oidc_access_token(can_prompt = FALSE) + expect_equal(access, "new_acc") + # Re-cached with the refreshed token. + expect_equal(.vmx_load_cached_token(cache)$access_token, "new_acc") + expect_equal(.vmx_load_cached_token(cache)$refresh_token, "ref_new") +}) + +test_that("refresh keeps the prior refresh token when the provider omits a new one", { + cache <- withr::local_tempfile(fileext = ".json") + local_oidc_env(cache) + expired <- .vmx_token( + access_token = "old_acc", refresh_token = "ref_keep", + expires_at = as.numeric(Sys.time()) - 10, + token_type = "Bearer", issuer = issuer, client_id = "test-cli" + ) + .vmx_save_cached_token(expired, cache) + + testthat::local_mocked_bindings( + .vmx_oidc_discover = function(config) list(device = "https://auth.test/device", token = "https://auth.test/token") + ) + httr2::local_mocked_responses(list( + httr2::response_json(body = list(access_token = "new_acc", expires_in = 600)) # no refresh_token + )) + + access <- vmx_oidc_access_token(can_prompt = FALSE) + expect_equal(access, "new_acc") + expect_equal(.vmx_load_cached_token(cache)$refresh_token, "ref_keep") +}) + +test_that("vmx_client auto-authenticates from a valid cached OIDC token", { + cache <- withr::local_tempfile(fileext = ".json") + local_oidc_env(cache) + .vmx_save_cached_token( + .vmx_token( + access_token = "cached_acc", refresh_token = "ref", + expires_at = as.numeric(Sys.time()) + 600, + token_type = "Bearer", issuer = issuer, client_id = "test-cli" + ), + cache + ) + + con <- vmx_client() + expect_s3_class(con, "vmx_client") + expect_equal(con$token, "cached_acc") +}) + +test_that("explicit token / PAT bypasses OIDC entirely", { + cache <- withr::local_tempfile(fileext = ".json") # never written + local_oidc_env(cache) + con <- vmx_client(token = "pat_abc") + expect_equal(con$token, "pat_abc") + expect_false(file.exists(cache)) +}) + +test_that("no PAT and no OIDC config raises vmx_auth_error naming both methods", { + withr::local_envvar( + VMX_API_TOKEN = "", VMX_OIDC_ISSUER = "", VMX_OIDC_CLIENT_ID = "", + VMX_API_BASE_URL = "https://vmx.test" + ) + expect_error(vmx_client(), class = "vmx_auth_error") +}) + +test_that("configured OIDC but no cache and non-interactive raises vmx_auth_error", { + cache <- withr::local_tempfile(fileext = ".json") # absent + local_oidc_env(cache) + expect_error(vmx_oidc_access_token(can_prompt = FALSE), class = "vmx_auth_error") +})