cjerzak commited on
Commit
f23dbdb
·
1 Parent(s): 7f40cff

add files

Browse files
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
- value <- headers[[key]] %||% headers[[toupper(key)]] %||% ""
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  asa_api_scalar_chr(value, default = "")
270
  }
271
 
272
- asa_api_extract_api_token <- function(req) {
273
- auth_header <- asa_api_get_header(req, "authorization")
274
- x_api_key <- asa_api_get_header(req, "x-api-key")
 
 
 
 
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
- trimws(x_api_key)
 
 
 
 
 
 
 
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,x-api-key")
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 (!startsWith(path, "/v1/")) {
76
  return(plumber::forward())
77
  }
78
 
79
- api_key <- trimws(Sys.getenv("ASA_API_KEY", unset = ""))
80
- if (!nzchar(api_key)) {
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 key auth is optional:
32
- - If `ASA_API_KEY` is set, `/v1/*` requires:
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 $ASA_API_KEY" \
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(`Content-Type` = "application/json") |>
9
- # If you enabled ASA_API_KEY, add:
10
- # req_headers(Authorization = paste("Bearer", Sys.getenv("ASA_API_KEY"))) |>
 
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 key 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 "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 (optional):</strong> If <code>ASA_API_KEY</code> is set, include <code>Authorization: Bearer &lt;key&gt;</code> or <code>x-api-key: &lt;key&gt;</code>.</p>
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>