Created
August 12, 2025 15:02
-
-
Save kellobri/d7668b88791b47a1767e79aa85a23853 to your computer and use it in GitHub Desktop.
Claude - OAuth 2.0 client credentials flow for your Plumber API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # OAuth 2.0 Client Credentials Flow for Plumber API | |
| ## Architecture Overview | |
| ``` | |
| Client App → Authorization Server → Access Token | |
| Client App → Plumber API (with token) → Validated Response | |
| ``` | |
| ## Step 1: Choose Your Authorization Server | |
| ### Option A: Azure AD (Recommended for Enterprise) | |
| - Use Azure App Registrations | |
| - Built-in token validation libraries | |
| - Enterprise-grade security | |
| ### Option B: Auth0 | |
| - Easy setup and management | |
| - Good documentation and SDKs | |
| - Pay-as-you-scale pricing | |
| ### Option C: Self-hosted Keycloak | |
| - Open source | |
| - Full control over configuration | |
| - Requires infrastructure management | |
| ## Step 2: Plumber API Implementation | |
| ### Install Required Packages | |
| ```r | |
| # Install these packages | |
| install.packages(c("plumber", "jose", "httr", "jsonlite")) | |
| ``` | |
| ### Token Validation Middleware | |
| ```r | |
| # auth_middleware.R | |
| library(jose) | |
| library(httr) | |
| library(jsonlite) | |
| # Configuration - set these as environment variables | |
| JWKS_URL <- Sys.getenv("JWKS_URL") # e.g., https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys | |
| ISSUER <- Sys.getenv("TOKEN_ISSUER") # e.g., https://login.microsoftonline.com/{tenant}/v2.0 | |
| AUDIENCE <- Sys.getenv("TOKEN_AUDIENCE") # Your API identifier | |
| # Cache for JWKS (JSON Web Key Set) | |
| jwks_cache <- list(keys = NULL, expires = NULL) | |
| get_jwks <- function() { | |
| # Check cache first | |
| if (!is.null(jwks_cache$expires) && Sys.time() < jwks_cache$expires) { | |
| return(jwks_cache$keys) | |
| } | |
| # Fetch fresh JWKS | |
| response <- GET(JWKS_URL) | |
| if (status_code(response) != 200) { | |
| stop("Failed to fetch JWKS") | |
| } | |
| jwks <- content(response, "parsed") | |
| # Cache for 1 hour | |
| jwks_cache$keys <<- jwks | |
| jwks_cache$expires <<- Sys.time() + 3600 | |
| return(jwks) | |
| } | |
| validate_token <- function(token) { | |
| tryCatch({ | |
| # Remove "Bearer " prefix if present | |
| token <- gsub("^Bearer ", "", token) | |
| # Get JWKS | |
| jwks <- get_jwks() | |
| # Decode and verify JWT | |
| claims <- jwt_decode_sig(token, jwks) | |
| # Validate issuer | |
| if (claims$iss != ISSUER) { | |
| return(list(valid = FALSE, error = "Invalid issuer")) | |
| } | |
| # Validate audience | |
| if (!AUDIENCE %in% claims$aud) { | |
| return(list(valid = FALSE, error = "Invalid audience")) | |
| } | |
| # Check expiration | |
| if (claims$exp < as.numeric(Sys.time())) { | |
| return(list(valid = FALSE, error = "Token expired")) | |
| } | |
| return(list(valid = TRUE, claims = claims)) | |
| }, error = function(e) { | |
| return(list(valid = FALSE, error = paste("Token validation failed:", e$message))) | |
| }) | |
| } | |
| # Plumber filter for authentication | |
| auth_filter <- function(req, res) { | |
| # Skip authentication for certain endpoints (optional) | |
| if (req$PATH_INFO %in% c("/health", "/docs")) { | |
| plumber::forward() | |
| return() | |
| } | |
| # Get Authorization header | |
| auth_header <- req$HTTP_AUTHORIZATION | |
| if (is.null(auth_header) || auth_header == "") { | |
| res$status <- 401 | |
| return(list(error = "Missing Authorization header")) | |
| } | |
| # Validate token | |
| validation_result <- validate_token(auth_header) | |
| if (!validation_result$valid) { | |
| res$status <- 401 | |
| return(list(error = validation_result$error)) | |
| } | |
| # Add claims to request for use in endpoints | |
| req$token_claims <- validation_result$claims | |
| plumber::forward() | |
| } | |
| ``` | |
| ### Main Plumber API | |
| ```r | |
| # plumber.R | |
| library(plumber) | |
| source("auth_middleware.R") | |
| #* @apiTitle Protected Plumber API | |
| #* @apiDescription API with OAuth 2.0 Client Credentials authentication | |
| # Apply authentication filter to all routes | |
| #* @filter auth | |
| auth_filter | |
| #* Health check endpoint (unprotected) | |
| #* @get /health | |
| function() { | |
| list(status = "healthy", timestamp = Sys.time()) | |
| } | |
| #* Get user data (protected) | |
| #* @get /api/data | |
| function(req) { | |
| # Access token claims | |
| client_id <- req$token_claims$sub # or azp for Azure AD | |
| list( | |
| message = "Access granted", | |
| client_id = client_id, | |
| scopes = req$token_claims$scp, | |
| data = list( | |
| id = 1, | |
| name = "Sample Data", | |
| timestamp = Sys.time() | |
| ) | |
| ) | |
| } | |
| #* Create resource (protected) | |
| #* @post /api/resource | |
| function(req, name, description = "") { | |
| client_id <- req$token_claims$sub | |
| # Your business logic here | |
| list( | |
| id = sample(1000:9999, 1), | |
| name = name, | |
| description = description, | |
| created_by = client_id, | |
| created_at = Sys.time() | |
| ) | |
| } | |
| #* @plumber | |
| function(pr) { | |
| pr %>% | |
| pr_set_api_spec(function(spec) { | |
| # Add OAuth 2.0 security scheme to OpenAPI spec | |
| spec$components$securitySchemes <- list( | |
| oauth2 = list( | |
| type = "oauth2", | |
| flows = list( | |
| clientCredentials = list( | |
| tokenUrl = paste0(ISSUER, "/oauth/v2.0/token"), # Adjust for your IdP | |
| scopes = list( | |
| "api.read" = "Read access to API", | |
| "api.write" = "Write access to API" | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| spec$security <- list(list(oauth2 = c("api.read", "api.write"))) | |
| spec | |
| }) | |
| } | |
| ``` | |
| ## Step 3: Environment Configuration | |
| Create a `.env` file or set environment variables: | |
| ```bash | |
| # For Azure AD | |
| JWKS_URL=https://login.microsoftonline.com/{your-tenant-id}/discovery/v2.0/keys | |
| TOKEN_ISSUER=https://login.microsoftonline.com/{your-tenant-id}/v2.0 | |
| TOKEN_AUDIENCE=api://your-api-identifier | |
| # For Auth0 | |
| JWKS_URL=https://{your-domain}.auth0.com/.well-known/jwks.json | |
| TOKEN_ISSUER=https://{your-domain}.auth0.com/ | |
| TOKEN_AUDIENCE=your-api-identifier | |
| ``` | |
| ## Step 4: Client Application Example | |
| ### R Client | |
| ```r | |
| # client_example.R | |
| library(httr) | |
| # Client credentials | |
| CLIENT_ID <- "your-client-id" | |
| CLIENT_SECRET <- "your-client-secret" | |
| TOKEN_URL <- "https://login.microsoftonline.com/{tenant}/oauth/v2.0/token" # Adjust for your IdP | |
| API_BASE_URL <- "https://your-connect-server.com/content/your-api-id" | |
| # Get access token | |
| get_access_token <- function() { | |
| response <- POST( | |
| TOKEN_URL, | |
| body = list( | |
| grant_type = "client_credentials", | |
| client_id = CLIENT_ID, | |
| client_secret = CLIENT_SECRET, | |
| scope = "api://your-api-identifier/.default" # Adjust scope | |
| ), | |
| encode = "form" | |
| ) | |
| if (status_code(response) != 200) { | |
| stop("Failed to get access token") | |
| } | |
| token_data <- content(response) | |
| return(token_data$access_token) | |
| } | |
| # Make authenticated API call | |
| call_api <- function(endpoint, method = "GET", body = NULL) { | |
| token <- get_access_token() | |
| response <- VERB( | |
| method, | |
| paste0(API_BASE_URL, endpoint), | |
| add_headers(Authorization = paste("Bearer", token)), | |
| body = body, | |
| encode = "json" | |
| ) | |
| return(content(response)) | |
| } | |
| # Usage examples | |
| data <- call_api("/api/data") | |
| print(data) | |
| new_resource <- call_api( | |
| "/api/resource", | |
| method = "POST", | |
| body = list(name = "Test Resource", description = "Created via API") | |
| ) | |
| print(new_resource) | |
| ``` | |
| ### Python Client | |
| ```python | |
| # client_example.py | |
| import requests | |
| import os | |
| class APIClient: | |
| def __init__(self, client_id, client_secret, token_url, api_base_url): | |
| self.client_id = client_id | |
| self.client_secret = client_secret | |
| self.token_url = token_url | |
| self.api_base_url = api_base_url | |
| self.access_token = None | |
| def get_access_token(self): | |
| data = { | |
| 'grant_type': 'client_credentials', | |
| 'client_id': self.client_id, | |
| 'client_secret': self.client_secret, | |
| 'scope': 'api://your-api-identifier/.default' | |
| } | |
| response = requests.post(self.token_url, data=data) | |
| response.raise_for_status() | |
| token_data = response.json() | |
| self.access_token = token_data['access_token'] | |
| return self.access_token | |
| def call_api(self, endpoint, method='GET', json_data=None): | |
| if not self.access_token: | |
| self.get_access_token() | |
| headers = {'Authorization': f'Bearer {self.access_token}'} | |
| url = f"{self.api_base_url}{endpoint}" | |
| response = requests.request(method, url, headers=headers, json=json_data) | |
| response.raise_for_status() | |
| return response.json() | |
| # Usage | |
| client = APIClient( | |
| client_id=os.getenv('CLIENT_ID'), | |
| client_secret=os.getenv('CLIENT_SECRET'), | |
| token_url=os.getenv('TOKEN_URL'), | |
| api_base_url=os.getenv('API_BASE_URL') | |
| ) | |
| # Get data | |
| data = client.call_api('/api/data') | |
| print(data) | |
| ``` | |
| ## Step 5: Azure AD Setup (Example) | |
| ### 1. Register Application | |
| ```bash | |
| # Using Azure CLI | |
| az ad app create \ | |
| --display-name "My Plumber API" \ | |
| --identifier-uris "api://my-plumber-api" | |
| az ad app create \ | |
| --display-name "API Client Service Principal" \ | |
| --app-roles '[{ | |
| "allowedMemberTypes": ["Application"], | |
| "displayName": "API Access", | |
| "id": "$(uuidgen)", | |
| "isEnabled": true, | |
| "description": "Allow access to API", | |
| "value": "api.access" | |
| }]' | |
| ``` | |
| ### 2. Create Client Secret | |
| ```bash | |
| az ad app credential reset --id {app-id} --append | |
| ``` | |
| ### 3. Grant Permissions | |
| ```bash | |
| az ad app permission add \ | |
| --id {client-app-id} \ | |
| --api {api-app-id} \ | |
| --api-permissions {permission-id}=Role | |
| az ad app permission admin-consent --id {client-app-id} | |
| ``` | |
| ## Step 6: Deployment to Posit Connect | |
| ### Deploy Script | |
| ```r | |
| # deploy.R | |
| library(rsconnect) | |
| # Set environment variables in Connect | |
| rsconnect::deployAPI( | |
| "plumber.R", | |
| server = "your-connect-server.com", | |
| account = "your-account", | |
| appName = "oauth-protected-api" | |
| ) | |
| ``` | |
| ### Environment Variables in Connect | |
| Set these in the Posit Connect dashboard under your app's "Vars" tab: | |
| - `JWKS_URL` | |
| - `TOKEN_ISSUER` | |
| - `TOKEN_AUDIENCE` | |
| ## Security Best Practices | |
| 1. **Use HTTPS everywhere** | |
| 2. **Implement proper CORS policies** | |
| 3. **Set appropriate token expiration times** | |
| 4. **Log authentication events** | |
| 5. **Implement rate limiting** | |
| 6. **Use principle of least privilege for scopes** | |
| 7. **Regularly rotate client secrets** | |
| 8. **Monitor for suspicious activity** | |
| ## Testing | |
| ### Unit Tests | |
| ```r | |
| # test_auth.R | |
| library(testthat) | |
| source("auth_middleware.R") | |
| test_that("Token validation works", { | |
| # Mock a valid token for testing | |
| # This would typically be generated by your test IdP | |
| valid_token <- "your-test-token" | |
| result <- validate_token(valid_token) | |
| expect_true(result$valid) | |
| }) | |
| ``` | |
| ### Integration Tests | |
| ```r | |
| # Test with actual IdP (use test/dev environment) | |
| test_client_credentials_flow <- function() { | |
| # Get token from IdP | |
| # Call your API | |
| # Verify response | |
| } | |
| ``` | |
| This implementation provides enterprise-grade OAuth 2.0 security for your Plumber API while keeping Posit Connect focused on hosting and deployment. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment