Skip to content

Commit d37f61b

Browse files
authored
feat(wasm): per-request credentials from session variables (#604)
* feat(openapi_fdw): session variable auth override for per-request credentials Adds `auth_token_setting` and `auth_token_prefix` server options to the OpenAPI FDW, allowing a Postgres session configuration variable to supply the authentication credential at query time rather than at server creation. The credential is resolved via `current_setting(name, true)` each time a request is made, so a security-definer wrapper function can inject a per-user or per-transaction token via `set_config(..., true)` before querying the foreign table. An empty or absent setting is a no-op, preserving any static credential configured at server level. Implementation: - Add `query-setting` to the utils WIT interface (v1 + v2) - Implement `query_setting()` in supabase-wrappers via SPI - Wire host binding in wasm_fdw/host/utils.rs (v1 + v2) - Add `auth_token_setting` / `auth_token_prefix` fields to ServerConfig - Extract `apply_session_token()` as a pure testable helper - Apply override in `make_request` before each HTTP call - Add 15 unit tests covering all override edge cases * test(openapi_fdw): integration test for session-token injection Adds a pgrx integration test exercising the auth_token_setting path end-to-end: with a session GUC unset the FDW injects no Authorization header, and after set_config(...) the resolved token is sent prefixed. This is the only runtime coverage of the query-setting host function -> SPI -> guest round-trip; the existing unit tests only cover the pure apply_session_token helper in isolation. - wasm_fdw/tests.rs: new #[pg_test] openapi_session_token_injection (negative + positive cases) against the local mock on :8096 - dockerfiles/wasm/server.py: add /whoami route that reflects the received Authorization header so the test can assert on it * fix(openapi_fdw): address review feedback - apply_session_token: match the authorization header case-insensitively so a static Authorization header (e.g. from the headers option) is replaced rather than duplicated - query_setting: report unexpected SPI errors instead of swallowing them, matching get_vault_secret (still returns None; absent settings are not errors thanks to current_setting(name, true)) - configure_auth: treat an empty/whitespace auth_token_setting as unset to avoid a pointless per-request lookup, and trim auth_token_prefix - tests: rename the prefix-default test to reflect the empty struct default, add a case-insensitive replace regression test * docs: document session-variable auth - openapi.md: auth_token_setting / auth_token_prefix server options, updated authentication limitation, and a per-request credentials example - wasm-advanced.md: new Host functions section documenting the utils interface, including query_setting for reading session GUCs - openapi_fdw README: note per-request session-variable tokens --------- Co-authored-by: Cody Bromley <codybrom@users.noreply.github.com>
1 parent bf3077d commit d37f61b

12 files changed

Lines changed: 385 additions & 5 deletions

File tree

docs/catalog/openapi.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ We need to provide Postgres with the credentials to access the API and any addit
105105
| `api_key_location` | No | Where to send the API key: `header` (default), `query`, or `cookie`. |
106106
| `bearer_token` | No | Bearer token for authentication (alternative to `api_key`). |
107107
| `bearer_token_id` | No | Vault secret key ID storing the bearer token. |
108+
| `auth_token_setting` | No | Name of a Postgres session variable (GUC) to read the auth token from at request time, e.g. `app.api_token`. When set and non-empty it overrides any static credential for that request. See [Per-request credentials](#per-request-credentials-session-variables). |
109+
| `auth_token_prefix` | No | Prefix for the `auth_token_setting` value in the Authorization header (default: `Bearer`). Set to an empty string to send the raw token. |
108110
| `user_agent` | No | Custom User-Agent header value. |
109111
| `accept` | No | Custom Accept header for content negotiation (e.g., `application/geo+json`). |
110112
| `headers` | No | Custom headers as JSON object (e.g., `'{"X-Custom": "value"}'`). |
@@ -402,7 +404,7 @@ options (endpoint '/users');
402404

403405
- **Read-only**: This FDW only supports SELECT operations. INSERT, UPDATE, and DELETE are not supported at this time.
404406
- **No transactions**: Each SQL statement results in immediate HTTP requests; there is no transactional grouping.
405-
- **Authentication**: Currently supports API Key and Bearer Token authentication. OAuth flows are not supported.
407+
- **Authentication**: Supports API Key and Bearer Token authentication, either static (server option or Vault) or resolved per request from a session variable (see [Per-request credentials](#per-request-credentials-session-variables)). The FDW does not run OAuth flows itself, but a session variable lets you supply a token your application already obtained.
406408
- **OpenAPI version**: Only OpenAPI 3.0+ specifications are supported (not Swagger 2.0).
407409

408410
## Automatic Retries
@@ -519,6 +521,37 @@ create server query_auth_api
519521
);
520522
```
521523

524+
### Per-request credentials (session variables)
525+
526+
A server's credential is normally fixed when the server is created. To vary it per request (for example, per-user OAuth tokens in a multi-tenant app) set `auth_token_setting` to the name of a Postgres session variable, then resolve that variable per query with a `SECURITY DEFINER` function:
527+
528+
```sql
529+
create server per_user_api
530+
foreign data wrapper wasm_wrapper
531+
options (
532+
fdw_package_url 'https://github.com/supabase/wrappers/releases/download/wasm_openapi_fdw_v0.2.0/openapi_fdw.wasm',
533+
fdw_package_name 'supabase:openapi-fdw',
534+
fdw_package_version '0.2.0',
535+
fdw_package_checksum 'f0d4d6e50f7c519a66363bd8bdbe1ea8086ca810ca14b43fb0ed18b64acdf6aa',
536+
base_url 'https://api.example.com',
537+
auth_token_setting 'app.api_token' -- read the token from this session variable each request
538+
);
539+
540+
-- Resolve the calling user's token (e.g. from an RLS-protected table keyed to
541+
-- auth.uid()) and pin it for the life of the transaction:
542+
create function set_api_token() returns void
543+
language sql security definer set search_path = '' as $$
544+
select set_config('app.api_token',
545+
(select access_token from public.user_tokens where user_id = auth.uid()),
546+
true);
547+
$$;
548+
549+
select set_api_token();
550+
select * from some_foreign_table;
551+
```
552+
553+
On each request the FDW reads `app.api_token` and sends it as `Authorization: Bearer <token>`. If the variable is unset or empty no token is injected, and any static credential on the server still applies. Use `auth_token_prefix` to change the `Bearer` prefix, or set it to an empty string to send the raw token.
554+
522555
### Response Path Extraction
523556

524557
For APIs that wrap data in a container object:

docs/guides/wasm-advanced.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,36 @@ fn iter_scan(ctx: &Context, row: &Row) -> Result<Option<u32>, FdwError> {
177177
}
178178
```
179179

180+
## Host functions
181+
182+
The host (the Wrappers Wasm runtime) exposes a small set of functions to the guest Wasm FDW through the `utils` interface.
183+
184+
| Function | Description |
185+
| --- | --- |
186+
| `utils::report_info` / `report_notice` / `report_warning` / `report_error` | Emit a PostgreSQL log message (visible in `psql`), handy for debugging. |
187+
| `utils::cell_to_string(cell)` | Format a `Cell` value as a string. |
188+
| `utils::get_vault_secret(id)` / `get_vault_secret_by_name(name)` | Read a decrypted secret from [Vault](https://supabase.com/docs/guides/database/vault). Use for static credentials. |
189+
| `utils::query_setting(name)` | Read a PostgreSQL session setting (GUC) at request time. Returns `None` if unset. |
190+
191+
### Reading session settings
192+
193+
`utils::query_setting(name)` wraps `current_setting(name, true)`, so it returns the current value of a session GUC, or `None` when it isn't set. This is useful when an FDW needs a value that varies per request, per user, or per transaction rather than a static server option.
194+
195+
```rust
196+
// inside your FDW, read a value set earlier in the same transaction
197+
if let Some(token) = utils::query_setting("app.api_token") {
198+
// use the token for this request
199+
}
200+
```
201+
202+
The value is supplied from SQL with `set_config`, typically from a `SECURITY DEFINER` function that resolves it for the calling user and scopes it to the transaction:
203+
204+
```sql
205+
select set_config('app.api_token', '<resolved-token>', true); -- true = transaction-local
206+
```
207+
208+
For example, the OpenAPI FDW's [`auth_token_setting`](../catalog/openapi.md#server-options) option reads a named session setting per request and uses it as the Authorization token.
209+
180210
## Developing locally
181211

182212
We'll use the CLI to develop locally. This will be faster than the GitHub release workflow.

supabase-wrappers/src/utils.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,25 @@ pub fn get_vault_secret(secret_id_or_name: &str) -> Option<String> {
388388
}
389389
}
390390

391+
/// Read a PostgreSQL session configuration parameter by name.
392+
///
393+
/// Calls `current_setting(name, true)` — returns `None` if the setting is absent
394+
/// rather than raising an error. Useful for injecting per-transaction credentials
395+
/// (e.g., via `set_config`) without requiring a fallback value. An unexpected SPI
396+
/// error is reported and surfaced as `None` rather than silently swallowed.
397+
pub fn query_setting(name: &str) -> Option<String> {
398+
match Spi::get_one_with_args::<String>("SELECT current_setting($1, true)", &[name.into()]) {
399+
Ok(value) => value,
400+
Err(err) => {
401+
report_error(
402+
PgSqlErrorCode::ERRCODE_FDW_ERROR,
403+
&format!("read session setting \"{name}\" failed: {err}"),
404+
);
405+
None
406+
}
407+
}
408+
}
409+
391410
/// Get decrypted secret from Vault by secret name
392411
///
393412
/// Get decrypted secret as string from Vault by secret name. Vault is an extension for storing

wasm-wrappers/fdw/openapi_fdw/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ Point it at an OpenAPI spec and query the API with SQL. The FDW parses the spec
1717
- **Rate-limit handling** — Retries automatically with exponential backoff on HTTP 429 responses
1818
- **Type coercion** — Maps JSON types to PostgreSQL types (`text`, `integer`, `boolean`, `timestamptz`, `jsonb`, etc.)
1919
- **camelCase matching** — Matches API field names like `stationIdentifier` to snake_case columns like `station_identifier`
20-
- **Auth support** — API key (header, query param, or cookie) and Bearer token authentication, with Supabase Vault integration
20+
- **Auth support** — API key (header, query param, or cookie) and Bearer token authentication, with Supabase Vault integration or per-request tokens from a session variable (`auth_token_setting`)
2121
- **Debug mode** — Set `debug 'true'` on the server to log HTTP request/response details as PostgreSQL INFO messages
2222

2323
## Limitations
2424

2525
- Read-only (no INSERT/UPDATE/DELETE)
2626
- POST-for-read available via `method` table option, but only GET endpoints are auto-imported
27-
- Auth: API key and Bearer token only (no OAuth2 flows — use pre-obtained tokens)
27+
- Auth: API key and Bearer token, static or resolved per request from a session variable (no OAuth2 flows — supply pre-obtained tokens)
2828
- OpenAPI 3.x only (Swagger 2.0 is rejected)
2929

3030
## Documentation

wasm-wrappers/fdw/openapi_fdw/src/config.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ pub(crate) struct ServerConfig {
3030
pub(crate) max_response_bytes: usize,
3131
pub(crate) debug: bool,
3232

33+
// Dynamic auth: Postgres session variable that overrides static auth at request time
34+
pub(crate) auth_token_setting: Option<String>,
35+
pub(crate) auth_token_prefix: String,
36+
3337
// Server-level defaults (saved after init, restored in begin_scan)
3438
pub(crate) default_page_size: usize,
3539
pub(crate) default_page_size_param: String,
@@ -65,6 +69,7 @@ impl std::fmt::Debug for ServerConfig {
6569
.field("max_pages", &self.max_pages)
6670
.field("max_response_bytes", &self.max_response_bytes)
6771
.field("debug", &self.debug)
72+
.field("auth_token_setting", &self.auth_token_setting)
6873
.finish()
6974
}
7075
}
@@ -84,6 +89,8 @@ impl Default for ServerConfig {
8489
max_pages: DEFAULT_MAX_PAGES,
8590
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
8691
debug: false,
92+
auth_token_setting: None,
93+
auth_token_prefix: String::new(),
8794
default_page_size: 0,
8895
default_page_size_param: String::new(),
8996
default_cursor_param: String::new(),
@@ -213,6 +220,16 @@ impl ServerConfig {
213220
);
214221
}
215222

223+
// Treat an empty/whitespace-only setting name as unset, so the request
224+
// path doesn't fire a pointless current_setting('') lookup per request.
225+
self.auth_token_setting = opts
226+
.get("auth_token_setting")
227+
.filter(|s| !s.trim().is_empty());
228+
self.auth_token_prefix = opts
229+
.require_or("auth_token_prefix", "Bearer")
230+
.trim()
231+
.to_owned();
232+
216233
Ok(())
217234
}
218235

@@ -280,6 +297,38 @@ impl ServerConfig {
280297

281298
Ok(())
282299
}
300+
301+
/// Apply a dynamically resolved token to a request headers list.
302+
///
303+
/// Replaces an existing `authorization` header if present, otherwise appends one.
304+
/// No-ops if `token` is empty or whitespace-only.
305+
///
306+
/// Separated from the WASM-dependent `make_request` call path for testability.
307+
pub(crate) fn apply_session_token(
308+
headers: &mut Vec<(String, String)>,
309+
token: &str,
310+
prefix: &str,
311+
) {
312+
if token.trim().is_empty() {
313+
return;
314+
}
315+
let value = if prefix.is_empty() {
316+
token.to_owned()
317+
} else {
318+
format!("{prefix} {token}")
319+
};
320+
// HTTP header names are case-insensitive: match any existing
321+
// authorization header regardless of casing so we replace rather than
322+
// append a duplicate (e.g. a static "Authorization" from the headers option).
323+
if let Some(h) = headers
324+
.iter_mut()
325+
.find(|h| h.0.eq_ignore_ascii_case("authorization"))
326+
{
327+
h.1 = value;
328+
} else {
329+
headers.push(("authorization".to_owned(), value));
330+
}
331+
}
283332
}
284333

285334
#[cfg(test)]

wasm-wrappers/fdw/openapi_fdw/src/config_tests.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,153 @@
11
use super::{DEFAULT_MAX_PAGES, DEFAULT_MAX_RESPONSE_BYTES, ServerConfig};
22

3+
// --- apply_session_token ---
4+
5+
#[test]
6+
fn test_session_token_default_none() {
7+
let config = ServerConfig::default();
8+
assert!(config.auth_token_setting.is_none());
9+
}
10+
11+
#[test]
12+
fn test_session_token_prefix_default_empty() {
13+
let config = ServerConfig::default();
14+
// Struct Default gives an empty prefix; configure_auth applies the real
15+
// "Bearer" default when the server option is absent.
16+
assert_eq!(config.auth_token_prefix, "");
17+
}
18+
19+
#[test]
20+
fn test_apply_session_token_adds_bearer_header() {
21+
let mut headers = vec![];
22+
ServerConfig::apply_session_token(&mut headers, "tok_abc123", "Bearer");
23+
assert_eq!(headers.len(), 1);
24+
assert_eq!(headers[0].0, "authorization");
25+
assert_eq!(headers[0].1, "Bearer tok_abc123");
26+
}
27+
28+
#[test]
29+
fn test_apply_session_token_custom_prefix() {
30+
let mut headers = vec![];
31+
ServerConfig::apply_session_token(&mut headers, "tok_xyz", "Token");
32+
assert_eq!(headers[0].1, "Token tok_xyz");
33+
}
34+
35+
#[test]
36+
fn test_apply_session_token_empty_prefix_no_prefix() {
37+
// Empty prefix → raw token value, no leading space
38+
let mut headers = vec![];
39+
ServerConfig::apply_session_token(&mut headers, "rawtoken", "");
40+
assert_eq!(headers[0].1, "rawtoken");
41+
}
42+
43+
#[test]
44+
fn test_apply_session_token_replaces_existing_authorization() {
45+
let mut headers = vec![("authorization".to_owned(), "Bearer old-token".to_owned())];
46+
ServerConfig::apply_session_token(&mut headers, "new-token", "Bearer");
47+
// Should replace, not append
48+
assert_eq!(headers.len(), 1);
49+
assert_eq!(headers[0].1, "Bearer new-token");
50+
}
51+
52+
#[test]
53+
fn test_apply_session_token_replaces_existing_authorization_case_insensitive() {
54+
// Existing header uses capital "Authorization" (e.g. from the headers option).
55+
// Header names are case-insensitive, so it should be replaced, not duplicated.
56+
let mut headers = vec![("Authorization".to_owned(), "Bearer old-token".to_owned())];
57+
ServerConfig::apply_session_token(&mut headers, "new-token", "Bearer");
58+
assert_eq!(headers.len(), 1);
59+
assert_eq!(headers[0].1, "Bearer new-token");
60+
}
61+
62+
#[test]
63+
fn test_apply_session_token_replaces_leaves_other_headers_intact() {
64+
let mut headers = vec![
65+
("content-type".to_owned(), "application/json".to_owned()),
66+
("authorization".to_owned(), "Bearer old".to_owned()),
67+
("x-request-id".to_owned(), "req-123".to_owned()),
68+
];
69+
ServerConfig::apply_session_token(&mut headers, "new", "Bearer");
70+
assert_eq!(headers.len(), 3);
71+
assert_eq!(headers[0].0, "content-type");
72+
assert_eq!(headers[1].1, "Bearer new");
73+
assert_eq!(headers[2].0, "x-request-id");
74+
}
75+
76+
#[test]
77+
fn test_apply_session_token_appends_when_no_existing_authorization() {
78+
let mut headers = vec![
79+
("content-type".to_owned(), "application/json".to_owned()),
80+
("user-agent".to_owned(), "Wrappers/1.0".to_owned()),
81+
];
82+
ServerConfig::apply_session_token(&mut headers, "tok", "Bearer");
83+
assert_eq!(headers.len(), 3);
84+
assert_eq!(headers[2].0, "authorization");
85+
assert_eq!(headers[2].1, "Bearer tok");
86+
}
87+
88+
#[test]
89+
fn test_apply_session_token_empty_token_noop() {
90+
let mut headers = vec![("authorization".to_owned(), "Bearer original".to_owned())];
91+
ServerConfig::apply_session_token(&mut headers, "", "Bearer");
92+
// Empty token → no change
93+
assert_eq!(headers[0].1, "Bearer original");
94+
}
95+
96+
#[test]
97+
fn test_apply_session_token_whitespace_token_noop() {
98+
let mut headers = vec![("authorization".to_owned(), "Bearer original".to_owned())];
99+
ServerConfig::apply_session_token(&mut headers, " ", "Bearer");
100+
assert_eq!(headers[0].1, "Bearer original");
101+
}
102+
103+
#[test]
104+
fn test_apply_session_token_empty_token_does_not_append() {
105+
let mut headers = vec![];
106+
ServerConfig::apply_session_token(&mut headers, "", "Bearer");
107+
assert!(headers.is_empty());
108+
}
109+
110+
#[test]
111+
fn test_apply_session_token_overrides_static_bearer() {
112+
// Simulate: static bearer_token set at init, session token injected at request time
113+
let mut headers = vec![
114+
("content-type".to_owned(), "application/json".to_owned()),
115+
("authorization".to_owned(), "Bearer static-token".to_owned()),
116+
];
117+
ServerConfig::apply_session_token(&mut headers, "per-user-token", "Bearer");
118+
let auth = headers.iter().find(|h| h.0 == "authorization").unwrap();
119+
assert_eq!(auth.1, "Bearer per-user-token");
120+
// Static token not leaked
121+
assert!(!auth.1.contains("static-token"));
122+
}
123+
124+
#[test]
125+
fn test_apply_session_token_second_call_replaces_first() {
126+
let mut headers = vec![];
127+
ServerConfig::apply_session_token(&mut headers, "first", "Bearer");
128+
ServerConfig::apply_session_token(&mut headers, "second", "Bearer");
129+
assert_eq!(headers.len(), 1);
130+
assert_eq!(headers[0].1, "Bearer second");
131+
}
132+
133+
#[test]
134+
fn test_debug_shows_auth_token_setting_name() {
135+
let config = ServerConfig {
136+
auth_token_setting: Some("app.user_token".to_string()),
137+
..Default::default()
138+
};
139+
let debug_output = format!("{config:?}");
140+
// Setting name is not sensitive — it should appear in debug output
141+
assert!(debug_output.contains("app.user_token"));
142+
}
143+
144+
#[test]
145+
fn test_debug_no_auth_token_setting_shows_none() {
146+
let config = ServerConfig::default();
147+
let debug_output = format!("{config:?}");
148+
assert!(debug_output.contains("auth_token_setting: None"));
149+
}
150+
3151
// --- Default values ---
4152

5153
#[test]

0 commit comments

Comments
 (0)