add files
Browse files- R/asa_api_helpers.R +31 -5
- R/plumber.R +4 -10
- README.md +5 -8
- Tests/api_contract_smoke.R +66 -0
- Tests/example_API_call.R +4 -3
- www/index.html +4 -4
R/asa_api_helpers.R
CHANGED
|
@@ -265,20 +265,46 @@ asa_api_bootstrap <- function() {
|
|
| 265 |
|
| 266 |
asa_api_get_header <- function(req, key) {
|
| 267 |
headers <- req$HEADERS %||% list()
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
asa_api_scalar_chr(value, default = "")
|
| 270 |
}
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
|
|
|
|
|
|
| 276 |
bearer <- sub("^Bearer\\s+", "", auth_header, ignore.case = TRUE)
|
| 277 |
if (nzchar(trimws(bearer)) && !identical(trimws(bearer), trimws(auth_header))) {
|
| 278 |
return(trimws(bearer))
|
| 279 |
}
|
| 280 |
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
}
|
| 283 |
|
| 284 |
asa_api_require_prompt <- function(payload) {
|
|
|
|
| 265 |
|
| 266 |
asa_api_get_header <- function(req, key) {
|
| 267 |
headers <- req$HEADERS %||% list()
|
| 268 |
+
header_names <- names(headers) %||% character(0)
|
| 269 |
+
value <- ""
|
| 270 |
+
|
| 271 |
+
if (length(header_names)) {
|
| 272 |
+
header_match <- which(tolower(header_names) == tolower(asa_api_scalar_chr(key, default = "")))
|
| 273 |
+
if (length(header_match)) {
|
| 274 |
+
value <- headers[[header_match[[1]]]]
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
if (!nzchar(asa_api_scalar_chr(value, default = ""))) {
|
| 279 |
+
value <- headers[[key]] %||% headers[[toupper(key)]] %||% ""
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
asa_api_scalar_chr(value, default = "")
|
| 283 |
}
|
| 284 |
|
| 285 |
+
asa_api_required_bearer_token <- function() {
|
| 286 |
+
"999"
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
asa_api_path_requires_bearer_auth <- function(path) {
|
| 290 |
+
startsWith(asa_api_scalar_chr(path, default = ""), "/v1/")
|
| 291 |
+
}
|
| 292 |
|
| 293 |
+
asa_api_extract_bearer_token <- function(req) {
|
| 294 |
+
auth_header <- asa_api_get_header(req, "authorization")
|
| 295 |
bearer <- sub("^Bearer\\s+", "", auth_header, ignore.case = TRUE)
|
| 296 |
if (nzchar(trimws(bearer)) && !identical(trimws(bearer), trimws(auth_header))) {
|
| 297 |
return(trimws(bearer))
|
| 298 |
}
|
| 299 |
|
| 300 |
+
""
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
asa_api_has_required_bearer_token <- function(req) {
|
| 304 |
+
identical(
|
| 305 |
+
asa_api_extract_bearer_token(req),
|
| 306 |
+
asa_api_required_bearer_token()
|
| 307 |
+
)
|
| 308 |
}
|
| 309 |
|
| 310 |
asa_api_require_prompt <- function(payload) {
|
R/plumber.R
CHANGED
|
@@ -59,7 +59,7 @@ function(req, res) {
|
|
| 59 |
allow_origin <- trimws(Sys.getenv("CORS_ALLOW_ORIGIN", unset = "*"))
|
| 60 |
res$setHeader("Access-Control-Allow-Origin", if (nzchar(allow_origin)) allow_origin else "*")
|
| 61 |
res$setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
| 62 |
-
res$setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type
|
| 63 |
|
| 64 |
if (identical(req$REQUEST_METHOD, "OPTIONS")) {
|
| 65 |
res$status <- 200L
|
|
@@ -72,18 +72,12 @@ function(req, res) {
|
|
| 72 |
#* @filter auth
|
| 73 |
function(req, res) {
|
| 74 |
path <- asa_api_scalar_chr(req$PATH_INFO, default = "")
|
| 75 |
-
if (!
|
| 76 |
return(plumber::forward())
|
| 77 |
}
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
return(plumber::forward())
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
supplied <- asa_api_extract_api_token(req)
|
| 85 |
-
if (!nzchar(supplied) || !identical(supplied, api_key)) {
|
| 86 |
-
return(asa_api_error_payload(res, 401L, "Unauthorized: missing or invalid API key."))
|
| 87 |
}
|
| 88 |
|
| 89 |
plumber::forward()
|
|
|
|
| 59 |
allow_origin <- trimws(Sys.getenv("CORS_ALLOW_ORIGIN", unset = "*"))
|
| 60 |
res$setHeader("Access-Control-Allow-Origin", if (nzchar(allow_origin)) allow_origin else "*")
|
| 61 |
res$setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
| 62 |
+
res$setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type")
|
| 63 |
|
| 64 |
if (identical(req$REQUEST_METHOD, "OPTIONS")) {
|
| 65 |
res$status <- 200L
|
|
|
|
| 72 |
#* @filter auth
|
| 73 |
function(req, res) {
|
| 74 |
path <- asa_api_scalar_chr(req$PATH_INFO, default = "")
|
| 75 |
+
if (!asa_api_path_requires_bearer_auth(path)) {
|
| 76 |
return(plumber::forward())
|
| 77 |
}
|
| 78 |
|
| 79 |
+
if (!asa_api_has_required_bearer_token(req)) {
|
| 80 |
+
return(asa_api_error_payload(res, 401L, "Unauthorized: submit Authorization: Bearer 999."))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
plumber::forward()
|
README.md
CHANGED
|
@@ -28,11 +28,8 @@ It uses:
|
|
| 28 |
|
| 29 |
## Security Model
|
| 30 |
|
| 31 |
-
- API
|
| 32 |
-
-
|
| 33 |
-
- `Authorization: Bearer <key>` or
|
| 34 |
-
- `x-api-key: <key>`
|
| 35 |
-
- If `ASA_API_KEY` is not set, `/v1/*` is public.
|
| 36 |
- GUI auth is password-based:
|
| 37 |
- `/gui/query` checks `GUI_PASSWORD`
|
| 38 |
|
|
@@ -45,7 +42,6 @@ Set these when running the container:
|
|
| 45 |
|
| 46 |
Optional secrets / vars:
|
| 47 |
|
| 48 |
-
- `ASA_API_KEY` (enables API token auth for `/v1/*`)
|
| 49 |
- `ASA_DEFAULT_BACKEND` (defaults to `gemini` if unset; examples: `openai`, `groq`, `anthropic`, `gemini`, `openrouter`)
|
| 50 |
- `ASA_DEFAULT_MODEL` (example: `gemini-2.5-flash`)
|
| 51 |
- `ASA_CONDA_ENV` (default: `asa_env`)
|
|
@@ -74,7 +70,7 @@ curl -s http://localhost:7860/healthz
|
|
| 74 |
```bash
|
| 75 |
curl -s http://localhost:7860/v1/run \
|
| 76 |
-H "Content-Type: application/json" \
|
| 77 |
-
-H "Authorization: Bearer
|
| 78 |
-d '{
|
| 79 |
"prompt": "What is the population of Tokyo?",
|
| 80 |
"config": {
|
|
@@ -92,6 +88,7 @@ curl -s http://localhost:7860/v1/run \
|
|
| 92 |
|
| 93 |
```bash
|
| 94 |
curl -s http://localhost:7860/v1/run \
|
|
|
|
| 95 |
-H "Content-Type: application/json" \
|
| 96 |
-d '{
|
| 97 |
"prompt": "Find Marie Curie birth year and nationality. Return JSON.",
|
|
@@ -105,6 +102,7 @@ curl -s http://localhost:7860/v1/run \
|
|
| 105 |
|
| 106 |
```bash
|
| 107 |
curl -s http://localhost:7860/v1/batch \
|
|
|
|
| 108 |
-H "Content-Type: application/json" \
|
| 109 |
-d '{
|
| 110 |
"prompts": [
|
|
@@ -211,7 +209,6 @@ Local run:
|
|
| 211 |
docker run --rm -p 7860:7860 \
|
| 212 |
-e GOOGLE_API_KEY=... \
|
| 213 |
-e GUI_PASSWORD=XXX \
|
| 214 |
-
-e ASA_API_KEY=optional_token \
|
| 215 |
asa-api
|
| 216 |
```
|
| 217 |
|
|
|
|
| 28 |
|
| 29 |
## Security Model
|
| 30 |
|
| 31 |
+
- API bearer auth is required for `/v1/*`:
|
| 32 |
+
- Include `Authorization: Bearer 999`
|
|
|
|
|
|
|
|
|
|
| 33 |
- GUI auth is password-based:
|
| 34 |
- `/gui/query` checks `GUI_PASSWORD`
|
| 35 |
|
|
|
|
| 42 |
|
| 43 |
Optional secrets / vars:
|
| 44 |
|
|
|
|
| 45 |
- `ASA_DEFAULT_BACKEND` (defaults to `gemini` if unset; examples: `openai`, `groq`, `anthropic`, `gemini`, `openrouter`)
|
| 46 |
- `ASA_DEFAULT_MODEL` (example: `gemini-2.5-flash`)
|
| 47 |
- `ASA_CONDA_ENV` (default: `asa_env`)
|
|
|
|
| 70 |
```bash
|
| 71 |
curl -s http://localhost:7860/v1/run \
|
| 72 |
-H "Content-Type: application/json" \
|
| 73 |
+
-H "Authorization: Bearer 999" \
|
| 74 |
-d '{
|
| 75 |
"prompt": "What is the population of Tokyo?",
|
| 76 |
"config": {
|
|
|
|
| 88 |
|
| 89 |
```bash
|
| 90 |
curl -s http://localhost:7860/v1/run \
|
| 91 |
+
-H "Authorization: Bearer 999" \
|
| 92 |
-H "Content-Type: application/json" \
|
| 93 |
-d '{
|
| 94 |
"prompt": "Find Marie Curie birth year and nationality. Return JSON.",
|
|
|
|
| 102 |
|
| 103 |
```bash
|
| 104 |
curl -s http://localhost:7860/v1/batch \
|
| 105 |
+
-H "Authorization: Bearer 999" \
|
| 106 |
-H "Content-Type: application/json" \
|
| 107 |
-d '{
|
| 108 |
"prompts": [
|
|
|
|
| 209 |
docker run --rm -p 7860:7860 \
|
| 210 |
-e GOOGLE_API_KEY=... \
|
| 211 |
-e GUI_PASSWORD=XXX \
|
|
|
|
| 212 |
asa-api
|
| 213 |
```
|
| 214 |
|
Tests/api_contract_smoke.R
CHANGED
|
@@ -110,6 +110,72 @@ assert_true(
|
|
| 110 |
"Direct-provider capability checks should agree on run_direct_task availability."
|
| 111 |
)
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
health_payload <- asa_api_health_payload()
|
| 114 |
assert_true(
|
| 115 |
identical(isTRUE(health_payload$direct_provider_available), isTRUE(asa_api_has_run_direct_task())),
|
|
|
|
| 110 |
"Direct-provider capability checks should agree on run_direct_task availability."
|
| 111 |
)
|
| 112 |
|
| 113 |
+
mock_request <- function(path = "/v1/run", authorization = NULL, x_api_key = NULL) {
|
| 114 |
+
headers <- list()
|
| 115 |
+
if (!is.null(authorization)) {
|
| 116 |
+
headers$authorization <- authorization
|
| 117 |
+
}
|
| 118 |
+
if (!is.null(x_api_key)) {
|
| 119 |
+
headers[["x-api-key"]] <- x_api_key
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
list(
|
| 123 |
+
PATH_INFO = path,
|
| 124 |
+
HEADERS = headers
|
| 125 |
+
)
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
assert_true(
|
| 129 |
+
isTRUE(asa_api_path_requires_bearer_auth("/v1/run")) &&
|
| 130 |
+
isTRUE(asa_api_path_requires_bearer_auth("/v1/batch")),
|
| 131 |
+
"`/v1/*` routes should require bearer auth."
|
| 132 |
+
)
|
| 133 |
+
assert_true(
|
| 134 |
+
!isTRUE(asa_api_path_requires_bearer_auth("/healthz")) &&
|
| 135 |
+
!isTRUE(asa_api_path_requires_bearer_auth("/gui/query")),
|
| 136 |
+
"Health and GUI routes should remain outside bearer auth scope."
|
| 137 |
+
)
|
| 138 |
+
assert_true(
|
| 139 |
+
identical(asa_api_required_bearer_token(), "999"),
|
| 140 |
+
"Bearer auth should require the fixed token 999."
|
| 141 |
+
)
|
| 142 |
+
assert_true(
|
| 143 |
+
identical(
|
| 144 |
+
asa_api_extract_bearer_token(mock_request(authorization = "Bearer 999")),
|
| 145 |
+
"999"
|
| 146 |
+
),
|
| 147 |
+
"Bearer extraction should accept the required token."
|
| 148 |
+
)
|
| 149 |
+
assert_true(
|
| 150 |
+
identical(
|
| 151 |
+
asa_api_extract_bearer_token(list(
|
| 152 |
+
PATH_INFO = "/v1/run",
|
| 153 |
+
HEADERS = list(Authorization = "Bearer 999")
|
| 154 |
+
)),
|
| 155 |
+
"999"
|
| 156 |
+
),
|
| 157 |
+
"Bearer extraction should match Authorization headers case-insensitively."
|
| 158 |
+
)
|
| 159 |
+
assert_true(
|
| 160 |
+
identical(
|
| 161 |
+
asa_api_extract_bearer_token(mock_request(authorization = "Basic 999")),
|
| 162 |
+
""
|
| 163 |
+
),
|
| 164 |
+
"Non-bearer Authorization schemes should not be accepted."
|
| 165 |
+
)
|
| 166 |
+
assert_true(
|
| 167 |
+
isTRUE(asa_api_has_required_bearer_token(mock_request(authorization = "Bearer 999"))),
|
| 168 |
+
"Bearer auth should accept Authorization: Bearer 999."
|
| 169 |
+
)
|
| 170 |
+
assert_true(
|
| 171 |
+
!isTRUE(asa_api_has_required_bearer_token(mock_request(authorization = "Bearer wrong"))),
|
| 172 |
+
"Bearer auth should reject the wrong token."
|
| 173 |
+
)
|
| 174 |
+
assert_true(
|
| 175 |
+
!isTRUE(asa_api_has_required_bearer_token(mock_request(x_api_key = "999"))),
|
| 176 |
+
"Legacy x-api-key auth should no longer be accepted."
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
health_payload <- asa_api_health_payload()
|
| 180 |
assert_true(
|
| 181 |
identical(isTRUE(health_payload$direct_provider_available), isTRUE(asa_api_has_run_direct_task())),
|
Tests/example_API_call.R
CHANGED
|
@@ -5,9 +5,10 @@ library(jsonlite)
|
|
| 5 |
base_url <- "http://localhost:7860"
|
| 6 |
|
| 7 |
resp <- request(paste0(base_url, "/v1/run")) |>
|
| 8 |
-
req_headers(
|
| 9 |
-
|
| 10 |
-
|
|
|
|
| 11 |
req_body_json(list(prompt = "Return the single word OK."), auto_unbox = TRUE) |>
|
| 12 |
req_perform()
|
| 13 |
|
|
|
|
| 5 |
base_url <- "http://localhost:7860"
|
| 6 |
|
| 7 |
resp <- request(paste0(base_url, "/v1/run")) |>
|
| 8 |
+
req_headers(
|
| 9 |
+
`Content-Type` = "application/json",
|
| 10 |
+
Authorization = "Bearer 999"
|
| 11 |
+
) |>
|
| 12 |
req_body_json(list(prompt = "Return the single word OK."), auto_unbox = TRUE) |>
|
| 13 |
req_perform()
|
| 14 |
|
www/index.html
CHANGED
|
@@ -353,15 +353,15 @@
|
|
| 353 |
</div>
|
| 354 |
|
| 355 |
<p class="hint">
|
| 356 |
-
API
|
| 357 |
</p>
|
| 358 |
<div class="hint api-guide" aria-label="API quickstart instructions">
|
| 359 |
<p><strong>API quickstart</strong></p>
|
| 360 |
<p><strong>Base URL:</strong> <code>https://asa.aidevlab.org</code></p>
|
| 361 |
<p><strong>Health:</strong> <code>GET /healthz</code> and <code>curl -s https://asa.aidevlab.org/healthz</code></p>
|
| 362 |
-
<p><strong>Run one prompt:</strong> <code>POST /v1/run</code> with JSON and <code>curl -s https://asa.aidevlab.org/v1/run -H "Content-Type: application/json" -d '{"prompt":"Hello","run":{"output_format":"text"}}'</code></p>
|
| 363 |
-
<p><strong>Run batch prompts:</strong> <code>POST /v1/batch</code> with a plain string array for <code>prompts</code> and <code>curl -s https://asa.aidevlab.org/v1/batch -H "Content-Type: application/json" -d '{"prompts":["Hello","World"],"run":{"output_format":"text"}}'</code></p>
|
| 364 |
-
<p><strong>Auth
|
| 365 |
</div>
|
| 366 |
</section>
|
| 367 |
</main>
|
|
|
|
| 353 |
</div>
|
| 354 |
|
| 355 |
<p class="hint">
|
| 356 |
+
API bearer auth does not apply to this GUI endpoint. Password is checked against <code>GUI_PASSWORD</code>, and provider selection remains server-side.
|
| 357 |
</p>
|
| 358 |
<div class="hint api-guide" aria-label="API quickstart instructions">
|
| 359 |
<p><strong>API quickstart</strong></p>
|
| 360 |
<p><strong>Base URL:</strong> <code>https://asa.aidevlab.org</code></p>
|
| 361 |
<p><strong>Health:</strong> <code>GET /healthz</code> and <code>curl -s https://asa.aidevlab.org/healthz</code></p>
|
| 362 |
+
<p><strong>Run one prompt:</strong> <code>POST /v1/run</code> with JSON and <code>curl -s https://asa.aidevlab.org/v1/run -H "Authorization: Bearer 999" -H "Content-Type: application/json" -d '{"prompt":"Hello","run":{"output_format":"text"}}'</code></p>
|
| 363 |
+
<p><strong>Run batch prompts:</strong> <code>POST /v1/batch</code> with a plain string array for <code>prompts</code> and <code>curl -s https://asa.aidevlab.org/v1/batch -H "Authorization: Bearer 999" -H "Content-Type: application/json" -d '{"prompts":["Hello","World"],"run":{"output_format":"text"}}'</code></p>
|
| 364 |
+
<p><strong>Auth:</strong> Include <code>Authorization: Bearer 999</code> on every <code>/v1/*</code> request.</p>
|
| 365 |
</div>
|
| 366 |
</section>
|
| 367 |
</main>
|