Skip to content

Instantly share code, notes, and snippets.

@kellobri
Created August 12, 2025 15:02
Show Gist options
  • Select an option

  • Save kellobri/d7668b88791b47a1767e79aa85a23853 to your computer and use it in GitHub Desktop.

Select an option

Save kellobri/d7668b88791b47a1767e79aa85a23853 to your computer and use it in GitHub Desktop.
Claude - OAuth 2.0 client credentials flow for your Plumber API
# 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