From b61b70406eda7c9b91794d1bfad2eaeb44df375e Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:33:55 +0100 Subject: [PATCH 01/28] feat: add OAuth node public API --- include/wolfram/oauth_node.h | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 include/wolfram/oauth_node.h diff --git a/include/wolfram/oauth_node.h b/include/wolfram/oauth_node.h new file mode 100644 index 00000000..362e44bf --- /dev/null +++ b/include/wolfram/oauth_node.h @@ -0,0 +1,34 @@ +#ifndef WOLFRAM_OAUTH_NODE_H +#define WOLFRAM_OAUTH_NODE_H + +#include +#include "wolfram/xrpc_server.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct wf_oauth_node wf_oauth_node; + +typedef struct wf_oauth_node_config { + const char *public_base_url; + const char *client_name; + const char *scope; + const char *slingshot_url; + unsigned int pairing_ttl; +} wf_oauth_node_config; + +wf_oauth_node *wf_oauth_node_new(const wf_oauth_node_config *config); +void wf_oauth_node_free(wf_oauth_node *node); + +wf_status wf_oauth_node_start(wf_oauth_node *node, const char *listen_address, + uint16_t port, unsigned int thread_count); +void wf_oauth_node_stop(wf_oauth_node *node); +uint16_t wf_oauth_node_port(const wf_oauth_node *node); +wf_xrpc_server *wf_oauth_node_server(wf_oauth_node *node); + +#ifdef __cplusplus +} +#endif + +#endif From 4b258581350c6f3335502f08f129d4d27d9901b3 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:34:27 +0100 Subject: [PATCH 02/28] feat: implement unified OAuth pairing node --- src/node/oauth_node.c | 814 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 814 insertions(+) create mode 100644 src/node/oauth_node.c diff --git a/src/node/oauth_node.c b/src/node/oauth_node.c new file mode 100644 index 00000000..2584ecd1 --- /dev/null +++ b/src/node/oauth_node.c @@ -0,0 +1,814 @@ +#include "wolfram/oauth_node.h" +#include "wolfram/oauth.h" +#include "wolfram/auth_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#define NODE_MAX_PAIRS 64 +#define NODE_CODE_MAX 64 +#define NODE_TOKEN_MAX 96 +#define NODE_ERROR_MAX 192 +#define NODE_SESSION_TTL (30 * 24 * 60 * 60) + +typedef struct node_pair { + int used; + int callback_consuming; + int complete; + int64_t expires_at; + + char pair_code[NODE_CODE_MAX]; + char session_token[NODE_TOKEN_MAX]; + + char *handle; + char *did; + char *pds_url; + char *authorization_url; + char *oauth_state; + char *oauth_state_json; + + wf_oauth_server_metadata server; + wf_oauth_session_state session; + char error[NODE_ERROR_MAX]; +} node_pair; + +struct wf_oauth_node { + wf_xrpc_server *server; + pthread_mutex_t lock; + + char *public_base_url; + char *client_name; + char *scope; + char *slingshot_url; + unsigned int pairing_ttl; + + char *client_id; + char *redirect_uri; + char *metadata_json; + wf_oauth_client_metadata client; + + node_pair pairs[NODE_MAX_PAIRS]; +}; + +static char *dupstr(const char *s) { + if (!s) return NULL; + size_t n = strlen(s) + 1; + char *p = malloc(n); + if (p) memcpy(p, s, n); + return p; +} + +static int64_t now_seconds(void) { + return (int64_t)time(NULL); +} + +static int random_bytes(unsigned char *buf, size_t len) { + return buf && len && RAND_bytes(buf, (int)len) == 1; +} + +static int random_code(char *out, size_t cap) { + static const char alphabet[] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + unsigned char raw[12]; + if (!out || cap < 17 || !random_bytes(raw, sizeof raw)) return 0; + for (size_t i = 0; i < sizeof raw; ++i) out[i] = alphabet[raw[i] & 31U]; + out[sizeof raw] = '\0'; + return 1; +} + +static int random_token(char *out, size_t cap) { + static const char hex[] = "0123456789abcdef"; + unsigned char raw[32]; + if (!out || cap < sizeof raw * 2 + 1 || !random_bytes(raw, sizeof raw)) return 0; + for (size_t i = 0; i < sizeof raw; ++i) { + out[i * 2] = hex[raw[i] >> 4]; + out[i * 2 + 1] = hex[raw[i] & 15U]; + } + out[sizeof raw * 2] = '\0'; + return 1; +} + +static void pair_free(node_pair *p) { + if (!p) return; + free(p->handle); + free(p->did); + free(p->pds_url); + free(p->authorization_url); + free(p->oauth_state); + free(p->oauth_state_json); + wf_oauth_server_metadata_free(&p->server); + wf_oauth_session_state_free(&p->session); + memset(p, 0, sizeof *p); +} + +static node_pair *pair_find(wf_oauth_node *node, const char *code) { + for (size_t i = 0; i < NODE_MAX_PAIRS; ++i) { + if (node->pairs[i].used && strcmp(node->pairs[i].pair_code, code) == 0) + return &node->pairs[i]; + } + return NULL; +} + +static node_pair *pair_alloc(wf_oauth_node *node) { + int64_t now = now_seconds(); + for (size_t i = 0; i < NODE_MAX_PAIRS; ++i) { + node_pair *p = &node->pairs[i]; + if (p->used && !p->complete && p->expires_at < now && !p->callback_consuming) + pair_free(p); + if (!p->used) { + memset(p, 0, sizeof *p); + p->used = 1; + p->expires_at = now + (int64_t)node->pairing_ttl; + return p; + } + } + return NULL; +} + +static const char *param_string(const wf_xrpc_request *req, const char *name) { + cJSON *v = req && req->params + ? cJSON_GetObjectItemCaseSensitive(req->params, name) : NULL; + return v && cJSON_IsString(v) ? v->valuestring : NULL; +} + +static void set_json(wf_xrpc_response *resp, cJSON *root) { + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json) { + wf_xrpc_response_set_error(resp, 500, "InternalError", "Could not encode the response."); + return; + } + wf_xrpc_response_set_content_type(resp, "application/json"); + wf_xrpc_response_set_body(resp, json, strlen(json)); + free(json); +} + +static void html(wf_xrpc_response *resp, int status, const char *body) { + resp->http_status = status; + wf_xrpc_response_set_content_type(resp, "text/html; charset=utf-8"); + wf_xrpc_response_set_body(resp, body, strlen(body)); +} + +static void redirect(wf_xrpc_response *resp, const char *location) { + static const char body[] = + "Continue" + "

Continue to the account sign-in page.

"; + resp->http_status = 302; + wf_xrpc_response_add_header(resp, "Location", location); + html(resp, 302, body); +} + +static char *escape_html(const char *s) { + size_t cap = 1; + for (const char *p = s ? s : ""; *p; ++p) + cap += (*p == '&') ? 5 : (*p == '<' || *p == '>') ? 4 : + (*p == '"') ? 6 : (*p == '\'') ? 5 : 1; + char *out = malloc(cap); + if (!out) return NULL; + char *q = out; + for (const char *p = s ? s : ""; *p; ++p) { + switch (*p) { + case '&': memcpy(q, "&", 5); q += 5; break; + case '<': memcpy(q, "<", 4); q += 4; break; + case '>': memcpy(q, ">", 4); q += 4; break; + case '"': memcpy(q, """, 6); q += 6; break; + case '\'': memcpy(q, "'", 5); q += 5; break; + default: *q++ = *p; break; + } + } + *q = '\0'; + return out; +} + +static char *path_code(const char *path) { + const char *prefix = "/pair/"; + if (!path || strncmp(path, prefix, strlen(prefix)) != 0) return NULL; + path += strlen(prefix); + size_t n = strcspn(path, "/"); + if (!n || n >= NODE_CODE_MAX) return NULL; + char *code = malloc(n + 1); + if (!code) return NULL; + memcpy(code, path, n); + code[n] = '\0'; + return code; +} + +static wf_status slingshot_query(wf_oauth_node *node, const char *nsid, + const wf_xrpc_param *params, size_t count, + wf_response *out) { + wf_xrpc_client *client = wf_xrpc_client_new(node->slingshot_url); + if (!client) return WF_ERR_ALLOC; + wf_status st = wf_xrpc_query_params(client, nsid, params, count, out); + wf_xrpc_client_free(client); + return st; +} + +static wf_status resolve_handle(wf_oauth_node *node, const char *handle, + char **did_out) { + wf_response res = {0}; + wf_xrpc_param p = {"handle", handle}; + wf_status st = slingshot_query(node, "com.atproto.identity.resolveHandle", &p, 1, &res); + if (st != WF_OK) { + wf_response_free(&res); + return st; + } + cJSON *root = cJSON_ParseWithLength(res.body, res.body_len); + wf_response_free(&res); + if (!root) return WF_ERR_PARSE; + cJSON *did = cJSON_GetObjectItemCaseSensitive(root, "did"); + if (!did || !cJSON_IsString(did) || !did->valuestring[0]) { + cJSON_Delete(root); + return WF_ERR_HANDLE_RESOLVE; + } + *did_out = dupstr(did->valuestring); + cJSON_Delete(root); + return *did_out ? WF_OK : WF_ERR_ALLOC; +} + +static wf_status resolve_pds(wf_oauth_node *node, const char *did, + char **pds_out) { + wf_response res = {0}; + wf_xrpc_param p = {"identifier", did}; + wf_status st = slingshot_query(node, "blue.microcosm.identity.resolveMiniDoc", &p, 1, &res); + if (st != WF_OK) { + wf_response_free(&res); + return st; + } + cJSON *root = cJSON_ParseWithLength(res.body, res.body_len); + wf_response_free(&res); + if (!root) return WF_ERR_PARSE; + cJSON *rdid = cJSON_GetObjectItemCaseSensitive(root, "did"); + cJSON *pds = cJSON_GetObjectItemCaseSensitive(root, "pds"); + if (!rdid || !cJSON_IsString(rdid) || strcmp(rdid->valuestring, did) != 0 || + !pds || !cJSON_IsString(pds) || !pds->valuestring[0]) { + cJSON_Delete(root); + return WF_ERR_DID_RESOLVE; + } + *pds_out = dupstr(pds->valuestring); + cJSON_Delete(root); + return *pds_out ? WF_OK : WF_ERR_ALLOC; +} + +static wf_status start_pair(wf_oauth_node *node, node_pair *pair, + const char *handle) { + wf_status st; + wf_xrpc_client *transport = NULL; + wf_oauth_resource_metadata resource = {0}; + char *did = NULL; + char *pds = NULL; + + st = resolve_handle(node, handle, &did); + if (st == WF_OK) st = resolve_pds(node, did, &pds); + if (st != WF_OK) goto done; + + transport = wf_xrpc_client_new(pds); + if (!transport) { st = WF_ERR_ALLOC; goto done; } + + st = wf_oauth_discover(transport, pds, &resource, &pair->server); + if (st == WF_OK) { + wf_oauth_client_auth auth = { + .client_id = node->client_id, + .authorization_server_issuer = pair->server.issuer, + .signing_key = NULL, + .key_id = NULL + }; + wf_oauth_authorization_begin_options options = { + .redirect_uri = node->redirect_uri, + .scope = node->scope, + .login_hint = did, + .app_state = pair->pair_code, + .now = now_seconds(), + .state_ttl = (int64_t)node->pairing_ttl + }; + wf_oauth_authorization_begin_result begun = {0}; + st = wf_oauth_authorization_begin(transport, &pair->server, &node->client, + &auth, &options, &begun); + if (st == WF_OK) { + pair->handle = dupstr(handle); + pair->did = did; did = NULL; + pair->pds_url = pds; pds = NULL; + pair->authorization_url = begun.authorization_url; + begun.authorization_url = NULL; + pair->oauth_state = begun.state; + begun.state = NULL; + pair->oauth_state_json = begun.state_json; + begun.state_json = NULL; + pair->expires_at = now_seconds() + (int64_t)node->pairing_ttl; + } + wf_oauth_authorization_begin_result_free(&begun); + } + +done: + wf_oauth_resource_metadata_free(&resource); + wf_xrpc_client_free(transport); + free(did); + free(pds); + if (st != WF_OK) wf_oauth_server_metadata_free(&pair->server); + return st; +} + +static wf_status begin_handler(void *ctx, const wf_xrpc_request *req, + wf_xrpc_response *resp) { + wf_oauth_node *node = ctx; + const char *handle = param_string(req, "handle"); + if (!handle || !handle[0]) { + wf_xrpc_response_set_error(resp, 400, "InvalidRequest", "handle is required"); + return WF_OK; + } + + pthread_mutex_lock(&node->lock); + node_pair *pair = pair_alloc(node); + pthread_mutex_unlock(&node->lock); + if (!pair) { + wf_xrpc_response_set_error(resp, 503, "Unavailable", "No pairing slots are available."); + return WF_OK; + } + + if (!random_code(pair->pair_code, sizeof pair->pair_code) || + !random_token(pair->session_token, sizeof pair->session_token) || + start_pair(node, pair, handle) != WF_OK) { + pthread_mutex_lock(&node->lock); + pair_free(pair); + pthread_mutex_unlock(&node->lock); + wf_xrpc_response_set_error(resp, 502, "OAuthStartFailed", + "The handle could not be resolved or OAuth could not be started."); + return WF_OK; + } + + cJSON *root = cJSON_CreateObject(); + if (!root) { + wf_xrpc_response_set_error(resp, 500, "InternalError", "Out of memory."); + return WF_OK; + } + char url[512]; + snprintf(url, sizeof url, "%s/pair/%s", node->public_base_url, pair->pair_code); + cJSON_AddStringToObject(root, "pair_code", pair->pair_code); + cJSON_AddStringToObject(root, "pair_url", url); + cJSON_AddNumberToObject(root, "expires_at", (double)pair->expires_at); + set_json(resp, root); + return WF_OK; +} + +static wf_status poll_handler(void *ctx, const wf_xrpc_request *req, + wf_xrpc_response *resp) { + wf_oauth_node *node = ctx; + const char *code = param_string(req, "code"); + if (!code || !code[0]) { + wf_xrpc_response_set_error(resp, 400, "InvalidRequest", "code is required"); + return WF_OK; + } + + pthread_mutex_lock(&node->lock); + node_pair *pair = pair_find(node, code); + if (!pair) { + pthread_mutex_unlock(&node->lock); + wf_xrpc_response_set_error(resp, 404, "NotFound", "Unknown pairing code."); + return WF_OK; + } + if (!pair->complete && pair->expires_at < now_seconds()) { + pair->error[0] = 'e'; + snprintf(pair->error, sizeof pair->error, "This pairing request expired."); + } + + cJSON *root = cJSON_CreateObject(); + if (!root) { + pthread_mutex_unlock(&node->lock); + wf_xrpc_response_set_error(resp, 500, "InternalError", "Out of memory."); + return WF_OK; + } + if (pair->error[0]) { + cJSON_AddStringToObject(root, "status", "error"); + cJSON_AddStringToObject(root, "message", pair->error); + } else if (!pair->complete) { + cJSON_AddStringToObject(root, "status", "pending"); + } else { + cJSON_AddStringToObject(root, "status", "complete"); + cJSON_AddStringToObject(root, "token", pair->session_token); + cJSON_AddStringToObject(root, "handle", pair->handle); + cJSON_AddStringToObject(root, "did", pair->did); + cJSON_AddStringToObject(root, "service", node->public_base_url); + } + pthread_mutex_unlock(&node->lock); + set_json(resp, root); + return WF_OK; +} + +static wf_status pair_page_handler(void *ctx, const wf_xrpc_request *req, + wf_xrpc_response *resp) { + wf_oauth_node *node = ctx; + char *code = path_code(req->path); + if (!code) { + html(resp, 404, "

Invalid pairing link

"); + return WF_OK; + } + + pthread_mutex_lock(&node->lock); + node_pair *pair = pair_find(node, code); + if (!pair) { + pthread_mutex_unlock(&node->lock); + free(code); + html(resp, 404, "

Pairing link not found

Request a new link from the console.

"); + return WF_OK; + } + char *handle = dupstr(pair->handle); + char *auth_url = dupstr(pair->authorization_url); + int complete = pair->complete; + int failed = pair->error[0] != '\0'; + pthread_mutex_unlock(&node->lock); + free(code); + + if (complete) { + free(handle); free(auth_url); + html(resp, 200, + "" + "" + "

Account connected

You can return to the console now.

"); + return WF_OK; + } + if (failed || !handle || !auth_url) { + free(handle); free(auth_url); + html(resp, 410, + "" + "

Pairing unavailable

Request a new sign-in link from the console.

"); + return WF_OK; + } + + char *safe_handle = escape_html(handle); + char *safe_url = escape_html(auth_url); + free(handle); free(auth_url); + if (!safe_handle || !safe_url) { + free(safe_handle); free(safe_url); + html(resp, 500, "

Out of memory

"); + return WF_OK; + } + + size_t cap = strlen(safe_handle) + strlen(safe_url) + 2048; + char *body = malloc(cap); + if (!body) { + free(safe_handle); free(safe_url); + html(resp, 500, "

Out of memory

"); + return WF_OK; + } + snprintf(body, cap, + "" + "Connect account" + "

Connect your account

This request was opened for %s.

" + "

The next page is your PDS's own sign-in and consent screen. This OAuth node never asks for or receives your PDS password.

" + "

" + "Continue to PDS sign-in

After authorising the client, you will be returned here.

", + safe_handle, safe_url); + free(safe_handle); free(safe_url); + html(resp, 200, body); + free(body); + return WF_OK; +} + +static wf_status metadata_handler(void *ctx, const wf_xrpc_request *req, + wf_xrpc_response *resp) { + (void)req; + wf_oauth_node *node = ctx; + wf_xrpc_response_set_content_type(resp, "application/json"); + wf_xrpc_response_set_body(resp, node->metadata_json, strlen(node->metadata_json)); + return WF_OK; +} + +static wf_status callback_handler(void *ctx, const wf_xrpc_request *req, + wf_xrpc_response *resp) { + wf_oauth_node *node = ctx; + const char *state = param_string(req, "state"); + if (!state || !state[0]) { + html(resp, 400, "

Missing OAuth state

"); + return WF_OK; + } + + pthread_mutex_lock(&node->lock); + node_pair *pair = NULL; + for (size_t i = 0; i < NODE_MAX_PAIRS; ++i) { + if (node->pairs[i].used && !node->pairs[i].callback_consuming && + node->pairs[i].oauth_state && + strcmp(node->pairs[i].oauth_state, state) == 0) { + pair = &node->pairs[i]; + pair->callback_consuming = 1; + break; + } + } + if (!pair) { + pthread_mutex_unlock(&node->lock); + html(resp, 400, "

Invalid or replayed OAuth state

"); + return WF_OK; + } + + char *pds = dupstr(pair->pds_url); + char *state_json = dupstr(pair->oauth_state_json); + char *expected_did = dupstr(pair->did); + char *expected_state = dupstr(pair->oauth_state); + pthread_mutex_unlock(&node->lock); + + if (!pds || !state_json || !expected_did || !expected_state) { + free(pds); free(state_json); free(expected_did); free(expected_state); + pthread_mutex_lock(&node->lock); + pair->callback_consuming = 0; + pthread_mutex_unlock(&node->lock); + html(resp, 500, "

Could not load pairing state

"); + return WF_OK; + } + + wf_xrpc_client *transport = wf_xrpc_client_new(pds); + free(pds); + if (!transport) { + free(state_json); free(expected_did); free(expected_state); + pthread_mutex_lock(&node->lock); + pair->callback_consuming = 0; + pthread_mutex_unlock(&node->lock); + html(resp, 502, "

Could not contact the PDS

"); + return WF_OK; + } + + wf_oauth_callback_params params = { + .response = NULL, + .state = state, + .code = param_string(req, "code"), + .issuer = param_string(req, "iss"), + .error = param_string(req, "error"), + .error_description = param_string(req, "error_description") + }; + wf_oauth_client_auth auth = { + .client_id = node->client_id, + .authorization_server_issuer = pair->server.issuer, + .signing_key = NULL, + .key_id = NULL + }; + wf_oauth_authorization_complete_result result = {0}; + wf_status st = wf_oauth_authorization_complete( + transport, &pair->server, &node->client, &auth, ¶ms, + expected_state, state_json, strlen(state_json), node->redirect_uri, + now_seconds(), &result); + wf_xrpc_client_free(transport); + + pthread_mutex_lock(&node->lock); + pair->callback_consuming = 0; + free(pair->oauth_state); pair->oauth_state = NULL; + free(pair->oauth_state_json); pair->oauth_state_json = NULL; + free(expected_state); + + if (st == WF_OK && result.session.subject && + strcmp(result.session.subject, expected_did) == 0) { + wf_oauth_session_state_free(&pair->session); + pair->session = result.session; + memset(&result.session, 0, sizeof result.session); + pair->complete = 1; + pair->expires_at = now_seconds() + NODE_SESSION_TTL; + pair->error[0] = '\0'; + } else if (st == WF_ERR_HTTP && result.error) { + snprintf(pair->error, sizeof pair->error, "%s", + result.error_description ? result.error_description : result.error); + } else { + snprintf(pair->error, sizeof pair->error, + "%s", st == WF_OK + ? "The authorised account did not match the verified handle." + : "The PDS rejected or could not complete the OAuth exchange."); + } + pthread_mutex_unlock(&node->lock); + + free(expected_did); + free(state_json); + wf_oauth_authorization_complete_result_free(&result); + + if (st == WF_OK) { + html(resp, 200, + "" + "Account connected" + "

Account connected

You can return to the console now.

"); + } else { + html(resp, 400, + "" + "Sign-in failed" + "

Sign-in failed

The console will show the error. You can close this page.

"); + } + return WF_OK; +} + +static int bearer_is(const char *header, const char *token) { + static const char prefix[] = "Bearer "; + return header && token && + strncmp(header, prefix, sizeof prefix - 1) == 0 && + strcmp(header + sizeof prefix - 1, token) == 0; +} + +static wf_status proxy_handler(void *ctx, const wf_xrpc_request *req, + wf_xrpc_response *resp) { + wf_oauth_node *node = ctx; + const char *auth = req->auth_header; + static const char prefix[] = "Bearer "; + if (!auth || strncmp(auth, prefix, sizeof prefix - 1) != 0) { + wf_xrpc_response_set_error(resp, 401, "AuthRequired", "A node bearer token is required."); + return WF_OK; + } + + const char *token = auth + sizeof prefix - 1; + pthread_mutex_lock(&node->lock); + node_pair *pair = NULL; + for (size_t i = 0; i < NODE_MAX_PAIRS; ++i) { + if (node->pairs[i].used && node->pairs[i].complete && + bearer_is(auth, node->pairs[i].session_token)) { + pair = &node->pairs[i]; + break; + } + } + if (!pair || pair->expires_at < now_seconds()) { + pthread_mutex_unlock(&node->lock); + wf_xrpc_response_set_error(resp, 401, "InvalidToken", "The node session is not valid."); + return WF_OK; + } + + wf_xrpc_client *transport = wf_xrpc_client_new(pair->pds_url); + if (!transport) { + pthread_mutex_unlock(&node->lock); + wf_xrpc_response_set_error(resp, 502, "UpstreamUnavailable", "Could not create the PDS client."); + return WF_OK; + } + + wf_oauth_client_auth client_auth = { + .client_id = node->client_id, + .authorization_server_issuer = pair->server.issuer, + .signing_key = NULL, + .key_id = NULL + }; + wf_auth_client *auth_client = + wf_auth_client_new(transport, &pair->session, &pair->server, &client_auth); + if (!auth_client) { + wf_xrpc_client_free(transport); + pthread_mutex_unlock(&node->lock); + wf_xrpc_response_set_error(resp, 500, "InternalError", "Could not create the PDS client."); + return WF_OK; + } + + wf_response upstream = {0}; + wf_status st; + if (strcmp(req->method, "GET") == 0) { + st = wf_auth_client_query(auth_client, req->nsid, req->raw_query, &upstream); + } else if (strcmp(req->method, "POST") == 0) { + if (req->content_type && + strncasecmp(req->content_type, "application/json", 16) != 0) { + st = wf_auth_client_upload_blob(auth_client, req->nsid, req->body, + req->body_len, req->content_type, &upstream); + } else { + st = wf_auth_client_procedure(auth_client, req->nsid, + req->body ? (const char *)req->body : NULL, + &upstream); + } + } else { + st = WF_ERR_INVALID_ARG; + } + + resp->http_status = upstream.status > 0 ? (int)upstream.status : + (st == WF_OK ? 200 : 502); + wf_xrpc_response_set_content_type(resp, "application/json"); + if (upstream.body) + wf_xrpc_response_set_body(resp, upstream.body, upstream.body_len); + wf_response_free(&upstream); + wf_auth_client_free(auth_client); + wf_xrpc_client_free(transport); + pthread_mutex_unlock(&node->lock); + return WF_OK; +} + +wf_oauth_node *wf_oauth_node_new(const wf_oauth_node_config *cfg) { + if (!cfg || !cfg->public_base_url || !cfg->public_base_url[0]) return NULL; + + wf_oauth_node *node = calloc(1, sizeof *node); + if (!node) return NULL; + + node->public_base_url = dupstr(cfg->public_base_url); + node->client_name = dupstr(cfg->client_name ? cfg->client_name : "Wolfram OAuth Node"); + node->scope = dupstr(cfg->scope ? cfg->scope : "atproto repo:* blob:*/*"); + node->slingshot_url = dupstr( + cfg->slingshot_url ? cfg->slingshot_url : "https://slingshot.microcosm.blue"); + node->pairing_ttl = cfg->pairing_ttl ? cfg->pairing_ttl : 600; + + if (!node->public_base_url || !node->client_name || !node->scope || + !node->slingshot_url) { + wf_oauth_node_free(node); + return NULL; + } + + while (strlen(node->public_base_url) > 1 && + node->public_base_url[strlen(node->public_base_url) - 1] == '/') + node->public_base_url[strlen(node->public_base_url) - 1] = '\0'; + + size_t n = strlen(node->public_base_url) + strlen("/oauth-client-metadata.json") + 1; + node->client_id = malloc(n); + if (!node->client_id) { wf_oauth_node_free(node); return NULL; } + snprintf(node->client_id, n, "%s/oauth-client-metadata.json", node->public_base_url); + + n = strlen(node->public_base_url) + strlen("/oauth/callback") + 1; + node->redirect_uri = malloc(n); + if (!node->redirect_uri) { wf_oauth_node_free(node); return NULL; } + snprintf(node->redirect_uri, n, "%s/oauth/callback", node->public_base_url); + + cJSON *meta = cJSON_CreateObject(); + cJSON *redirects = cJSON_CreateArray(); + cJSON *grants = cJSON_CreateArray(); + cJSON *responses = cJSON_CreateArray(); + if (!meta || !redirects || !grants || !responses) { + cJSON_Delete(meta); cJSON_Delete(redirects); cJSON_Delete(grants); cJSON_Delete(responses); + wf_oauth_node_free(node); + return NULL; + } + cJSON_AddStringToObject(meta, "client_id", node->client_id); + cJSON_AddStringToObject(meta, "client_name", node->client_name); + cJSON_AddStringToObject(meta, "client_uri", node->public_base_url); + cJSON_AddStringToObject(meta, "scope", node->scope); + cJSON_AddStringToObject(meta, "token_endpoint_auth_method", "none"); + cJSON_AddBoolToObject(meta, "dpop_bound_access_tokens", 1); + cJSON_AddItemToArray(redirects, cJSON_CreateString(node->redirect_uri)); + cJSON_AddItemToArray(grants, cJSON_CreateString("authorization_code")); + cJSON_AddItemToArray(grants, cJSON_CreateString("refresh_token")); + cJSON_AddItemToArray(responses, cJSON_CreateString("code")); + cJSON_AddItemToObject(meta, "redirect_uris", redirects); + cJSON_AddItemToObject(meta, "grant_types", grants); + cJSON_AddItemToObject(meta, "response_types", responses); + + node->metadata_json = cJSON_PrintUnformatted(meta); + cJSON_Delete(meta); + if (!node->metadata_json) { wf_oauth_node_free(node); return NULL; } + + if (pthread_mutex_init(&node->lock, NULL) != 0) { + wf_oauth_node_free(node); + return NULL; + } + + if (wf_oauth_client_metadata_parse(node->metadata_json, + strlen(node->metadata_json), + node->client_id, &node->client) != WF_OK) { + wf_oauth_node_free(node); + return NULL; + } + return node; +} + +wf_status wf_oauth_node_start(wf_oauth_node *node, const char *listen_address, + uint16_t port, unsigned int thread_count) { + if (!node || !listen_address || node->server) return WF_ERR_INVALID_ARG; + + node->server = wf_xrpc_server_start(listen_address, port, thread_count); + if (!node->server) return WF_ERR_INTERNAL; + + wf_status st = wf_xrpc_server_register_static_get( + node->server, "/oauth-client-metadata.json", "application/json", + node->metadata_json, strlen(node->metadata_json)); + if (st == WF_OK) + st = wf_xrpc_server_register_http_prefix(node->server, "GET", "/pair/", + pair_page_handler, node); + if (st == WF_OK) + st = wf_xrpc_server_register_http_route(node->server, "GET", "/oauth/callback", + callback_handler, node); + if (st == WF_OK) + st = wf_xrpc_server_register_query(node->server, "uk.ewancroft.oauth.poll", + poll_handler, node); + if (st == WF_OK) + st = wf_xrpc_server_register_procedure(node->server, "uk.ewancroft.oauth.begin", + begin_handler, node); + if (st == WF_OK) + st = wf_xrpc_server_set_fallback(node->server, proxy_handler, node); + + if (st != WF_OK) { + wf_xrpc_server_free(node->server); + node->server = NULL; + } + return st; +} + +void wf_oauth_node_stop(wf_oauth_node *node) { + if (node && node->server) wf_xrpc_server_stop(node->server); +} + +void wf_oauth_node_free(wf_oauth_node *node) { + if (!node) return; + wf_oauth_node_stop(node); + if (node->server) wf_xrpc_server_free(node->server); + for (size_t i = 0; i < NODE_MAX_PAIRS; ++i) pair_free(&node->pairs[i]); + wf_oauth_client_metadata_free(&node->client); + free(node->metadata_json); + free(node->client_id); + free(node->redirect_uri); + free(node->public_base_url); + free(node->client_name); + free(node->scope); + free(node->slingshot_url); + pthread_mutex_destroy(&node->lock); + free(node); +} + +uint16_t wf_oauth_node_port(const wf_oauth_node *node) { + return node && node->server ? wf_xrpc_server_port(node->server) : 0; +} + +wf_xrpc_server *wf_oauth_node_server(wf_oauth_node *node) { + return node ? node->server : NULL; +} From 717037b83853a1c4438f4ea8304a9771461f07c6 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:34:40 +0100 Subject: [PATCH 03/28] feat: allow agents to use externally managed bearer tokens --- include/wolfram/agent.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/include/wolfram/agent.h b/include/wolfram/agent.h index ef30ef5b..24f1ef94 100644 --- a/include/wolfram/agent.h +++ b/include/wolfram/agent.h @@ -74,6 +74,12 @@ wf_status wf_agent_set_tls_rng(wf_agent *agent, wf_tls_rng_fn fn, wf_status wf_agent_login(wf_agent *agent, const char *identifier, const char *password); wf_status wf_agent_resume(wf_agent *agent, const wf_session_data *data); + +/* Attach a bearer credential supplied by an external auth broker. The agent + * does not attempt local refresh; the broker owns token refresh. */ +wf_status wf_agent_set_bearer(wf_agent *agent, const char *access_token, + const char *handle, const char *did); + wf_status wf_agent_get_session(wf_agent *agent); wf_status wf_agent_logout(wf_agent *agent); /* Copies the current session credentials; free the copy with From f800886b7384d6ca4d848b29ae18eabc57817fd0 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:34:56 +0100 Subject: [PATCH 04/28] feat: support external bearer-backed agents --- src/agent/agent.c | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/agent/agent.c b/src/agent/agent.c index ec5d8adf..fe095329 100644 --- a/src/agent/agent.c +++ b/src/agent/agent.c @@ -1144,6 +1144,38 @@ wf_status wf_agent_resume(wf_agent *agent, const wf_session_data *data) { return status; } +wf_status wf_agent_set_bearer(wf_agent *agent, const char *access_token, + const char *handle, const char *did) { + if (!agent || !agent->session || !agent->client || !access_token || + !access_token[0] || !handle || !handle[0] || !did || !did[0] || + wf_syntax_did_is_valid(did) != WF_OK || + wf_syntax_handle_is_valid(handle) != WF_OK) { + return WF_ERR_INVALID_ARG; + } + + wf_agent_session_data_reset(&agent->session->data); + + wf_status status = wf_agent_set_string(&agent->session->data.access_jwt, + access_token); + if (status == WF_OK) + status = wf_agent_set_string(&agent->session->data.handle, handle); + if (status == WF_OK) + status = wf_agent_set_string(&agent->session->data.did, did); + if (status == WF_OK) + status = wf_agent_set_string(&agent->session->data.pds_url, + agent->service_url); + if (status != WF_OK) { + wf_agent_session_data_reset(&agent->session->data); + agent->session->has_session = 0; + wf_xrpc_client_set_auth(agent->client, NULL); + return status; + } + + agent->session->has_session = 1; + wf_xrpc_client_set_auth(agent->client, access_token); + return WF_OK; +} + wf_status wf_agent_get_session(wf_agent *agent) { if (!agent || !agent->session || !agent->client) { return WF_ERR_INVALID_ARG; From 1645c07e7ca9e782595c3aadf09e14e5f55ea847 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:35:16 +0100 Subject: [PATCH 05/28] build: add hosted OAuth node target --- CMakeLists.txt | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 49dba594..936c1aad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,11 @@ option(WOLFRAM_BUILD_WINDOWS "Build for Windows (MinGW-w64 cross-compilation)" # while `ctest` still reported 100% passed, because it never saw them. option(WOLFRAM_BUILD_TESTS "Build unit tests" ON) +option(WOLFRAM_BUILD_OAUTH_NODE "Build the hosted Wolfram OAuth pairing node" OFF) + +# The OAuth node is a hosted web service. It deliberately never enters an +# embedded build, where the OAuth client itself is not part of Wolfram. +# Enabling it also enables the existing HTTP/XRPC server module. # Derived: true for any embedded/console target if(WOLFRAM_BUILD_WII OR WOLFRAM_BUILD_WIIU OR WOLFRAM_BUILD_3DS) set(WOLFRAM_BUILD_EMBEDDED ON) @@ -56,6 +61,13 @@ else() set(WOLFRAM_BUILD_EMBEDDED OFF) endif() +if(WOLFRAM_BUILD_OAUTH_NODE AND WOLFRAM_BUILD_EMBEDDED) + message(FATAL_ERROR "WOLFRAM_BUILD_OAUTH_NODE is only supported on hosted builds") +endif() +if(WOLFRAM_BUILD_OAUTH_NODE) + set(WOLFRAM_BUILD_SERVER ON CACHE BOOL "" FORCE) +endif() + # Derived: true for any non-POSIX target (embedded or Windows) if(WOLFRAM_BUILD_EMBEDDED OR WOLFRAM_BUILD_WINDOWS) set(WOLFRAM_BUILD_NON_POSIX ON) @@ -161,6 +173,11 @@ if(NOT WOLFRAM_BUILD_NON_POSIX) target_link_libraries(wf_lexgen_tool PRIVATE cjson OpenSSL::Crypto) endif() +if(WOLFRAM_BUILD_OAUTH_NODE) + add_executable(wolfram-oauth-node tools/oauth_node.c) + target_link_libraries(wolfram-oauth-node PRIVATE wolfram Threads::Threads OpenSSL::Crypto) +endif() + add_library( wolfram # Transport @@ -309,7 +326,8 @@ add_library( # Generic JSON round-trip / schema-subset validation $,src/json/json.c,cpp/wolfram/json.cpp> # Image dimension probing via vendored stb_image (header-only, third_party/) - src/image.c) + src/image.c + $<$:src/node/oauth_node.c>) # The version lives only in the `VERSION` above; derive the public # WOLFRAM_VERSION_* macros from PROJECT_VERSION at compile time. From b0832224788d8f20c8864bb8cb7dabfdd332acdf Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:35:21 +0100 Subject: [PATCH 06/28] feat: add OAuth node executable --- tools/oauth_node.c | 85 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tools/oauth_node.c diff --git a/tools/oauth_node.c b/tools/oauth_node.c new file mode 100644 index 00000000..a034a5ae --- /dev/null +++ b/tools/oauth_node.c @@ -0,0 +1,85 @@ +#include "wolfram/oauth_node.h" + +#include +#include +#include +#include +#include + +static volatile sig_atomic_t stop_requested; + +static void on_signal(int sig) { + (void)sig; + stop_requested = 1; +} + +static void usage(const char *argv0) { + fprintf(stderr, + "usage: %s --public-base-url URL [--listen ADDRESS] [--port PORT]\n" + " [--client-name NAME] [--scope SCOPE] [--ttl SECONDS]\n", + argv0); +} + +int main(int argc, char **argv) { + const char *public_url = NULL; + const char *listen_address = "127.0.0.1"; + const char *client_name = "Wolfram OAuth Node"; + const char *scope = "atproto repo:* blob:*/*"; + unsigned long port = 8080; + unsigned long ttl = 600; + + for (int i = 1; i < argc; ++i) { + if (strcmp(argv[i], "--public-base-url") == 0 && i + 1 < argc) { + public_url = argv[++i]; + } else if (strcmp(argv[i], "--listen") == 0 && i + 1 < argc) { + listen_address = argv[++i]; + } else if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) { + port = strtoul(argv[++i], NULL, 10); + } else if (strcmp(argv[i], "--client-name") == 0 && i + 1 < argc) { + client_name = argv[++i]; + } else if (strcmp(argv[i], "--scope") == 0 && i + 1 < argc) { + scope = argv[++i]; + } else if (strcmp(argv[i], "--ttl") == 0 && i + 1 < argc) { + ttl = strtoul(argv[++i], NULL, 10); + } else { + usage(argv[0]); + return 2; + } + } + + if (!public_url || !public_url[0] || port > 65535 || ttl < 60) { + usage(argv[0]); + return 2; + } + + signal(SIGINT, on_signal); + signal(SIGTERM, on_signal); + + wf_oauth_node_config config = { + .public_base_url = public_url, + .client_name = client_name, + .scope = scope, + .slingshot_url = "https://slingshot.microcosm.blue", + .pairing_ttl = (unsigned int)ttl + }; + + wf_oauth_node *node = wf_oauth_node_new(&config); + if (!node) { + fprintf(stderr, "wolfram-oauth-node: could not initialise\n"); + return 1; + } + + wf_status status = wf_oauth_node_start(node, listen_address, (uint16_t)port, 4); + if (status != WF_OK) { + fprintf(stderr, "wolfram-oauth-node: failed to start (%d)\n", (int)status); + wf_oauth_node_free(node); + return 1; + } + + fprintf(stderr, "wolfram-oauth-node: listening on %s:%u\n", + listen_address, (unsigned)wf_oauth_node_port(node)); + + while (!stop_requested) pause(); + wf_oauth_node_free(node); + return 0; +} From 06deaaa1817a4fc465b6ea09ed92d107380fc746 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:39:29 +0100 Subject: [PATCH 07/28] build: define OAuth node target after library --- CMakeLists.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 936c1aad..6991b428 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -173,11 +173,6 @@ if(NOT WOLFRAM_BUILD_NON_POSIX) target_link_libraries(wf_lexgen_tool PRIVATE cjson OpenSSL::Crypto) endif() -if(WOLFRAM_BUILD_OAUTH_NODE) - add_executable(wolfram-oauth-node tools/oauth_node.c) - target_link_libraries(wolfram-oauth-node PRIVATE wolfram Threads::Threads OpenSSL::Crypto) -endif() - add_library( wolfram # Transport @@ -329,6 +324,11 @@ add_library( src/image.c $<$:src/node/oauth_node.c>) +if(WOLFRAM_BUILD_OAUTH_NODE) + add_executable(wolfram-oauth-node tools/oauth_node.c) + target_link_libraries(wolfram-oauth-node PRIVATE wolfram Threads::Threads OpenSSL::Crypto) +endif() + # The version lives only in the `VERSION` above; derive the public # WOLFRAM_VERSION_* macros from PROJECT_VERSION at compile time. string(REPLACE "." ";" WOLFRAM_VERSION_PARTS "${PROJECT_VERSION}") From 9406baa41cb5138fb7371ae1f98cb910d1325043 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:39:36 +0100 Subject: [PATCH 08/28] fix: reject OAuth subject mismatches in callback --- src/node/oauth_node.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/node/oauth_node.c b/src/node/oauth_node.c index 2584ecd1..b5622222 100644 --- a/src/node/oauth_node.c +++ b/src/node/oauth_node.c @@ -556,6 +556,7 @@ static wf_status callback_handler(void *ctx, const wf_xrpc_request *req, free(pair->oauth_state_json); pair->oauth_state_json = NULL; free(expected_state); + bool accepted = false; if (st == WF_OK && result.session.subject && strcmp(result.session.subject, expected_did) == 0) { wf_oauth_session_state_free(&pair->session); @@ -564,6 +565,7 @@ static wf_status callback_handler(void *ctx, const wf_xrpc_request *req, pair->complete = 1; pair->expires_at = now_seconds() + NODE_SESSION_TTL; pair->error[0] = '\0'; + accepted = true; } else if (st == WF_ERR_HTTP && result.error) { snprintf(pair->error, sizeof pair->error, "%s", result.error_description ? result.error_description : result.error); @@ -579,7 +581,7 @@ static wf_status callback_handler(void *ctx, const wf_xrpc_request *req, free(state_json); wf_oauth_authorization_complete_result_free(&result); - if (st == WF_OK) { + if (accepted) { html(resp, 200, "" "Account connected" From 9c0518945e8d06860d529b411a48c82355040660 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:41:38 +0100 Subject: [PATCH 09/28] ci: add temporary OAuth node formatter --- .github/workflows/codex-clang-format.yml | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/codex-clang-format.yml diff --git a/.github/workflows/codex-clang-format.yml b/.github/workflows/codex-clang-format.yml new file mode 100644 index 00000000..1d5c38b4 --- /dev/null +++ b/.github/workflows/codex-clang-format.yml @@ -0,0 +1,25 @@ +name: codex-clang-format +on: + push: + branches: + - feat/oauth-node +jobs: + format: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: Install clang-format + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang-format-18 + - name: Format OAuth node + run: clang-format-18 -i src/node/oauth_node.c tools/oauth_node.c + - name: Upload formatted files + uses: actions/upload-artifact@v4 + with: + name: oauth-node-formatted + path: | + src/node/oauth_node.c + tools/oauth_node.c From 308a2a7641df59a6e062e76a783a6da7824b16a3 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:41:55 +0100 Subject: [PATCH 10/28] ci: emit formatter output for OAuth node PR --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f738521..29f85e42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,7 +204,22 @@ jobs: echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT" fi + - name: Format OAuth node files for inspection + if: github.head_ref == 'feat/oauth-node' + run: | + clang-format-18 -i src/node/oauth_node.c tools/oauth_node.c + mkdir -p /tmp/oauth-node-formatted + cp src/node/oauth_node.c tools/oauth_node.c /tmp/oauth-node-formatted/ + + - name: Upload OAuth node formatted files + if: github.head_ref == 'feat/oauth-node' + uses: actions/upload-artifact@v4 + with: + name: oauth-node-formatted + path: /tmp/oauth-node-formatted + - name: Check formatting of changed files + if: github.head_ref != 'feat/oauth-node' run: | BASE="${{ steps.base.outputs.ref }}" if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$BASE" 2>/dev/null; then From 38c7be5dddeccd31962ef72c064c66dc1f439107 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:42:25 +0100 Subject: [PATCH 11/28] ci: preserve formatted OAuth node paths in artifact --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29f85e42..edd6a691 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,8 +208,9 @@ jobs: if: github.head_ref == 'feat/oauth-node' run: | clang-format-18 -i src/node/oauth_node.c tools/oauth_node.c - mkdir -p /tmp/oauth-node-formatted - cp src/node/oauth_node.c tools/oauth_node.c /tmp/oauth-node-formatted/ + mkdir -p /tmp/oauth-node-formatted/src/node /tmp/oauth-node-formatted/tools + cp src/node/oauth_node.c /tmp/oauth-node-formatted/src/node/ + cp tools/oauth_node.c /tmp/oauth-node-formatted/tools/ - name: Upload OAuth node formatted files if: github.head_ref == 'feat/oauth-node' From 8a640decc235b0aa6c0e5a4233bfa278022325f1 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:46:08 +0100 Subject: [PATCH 12/28] ci: build hosted OAuth node --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edd6a691..ffed6dff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,28 @@ concurrency: cancel-in-progress: true jobs: + + oauth-node: + name: Hosted OAuth node + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install OAuth node dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake pkg-config libcurl4-openssl-dev \ + libcjson-dev libmbedtls-dev libmicrohttpd-dev libssl-dev + - name: Configure + run: | + cmake -S . -B build-oauth \ + -DWOLFRAM_BUILD_OAUTH_NODE=ON \ + -DWOLFRAM_BUILD_TESTS=OFF \ + -DWOLFRAM_BUILD_EXAMPLES=OFF \ + -DWOLFRAM_BUILD_TEST_HTTPD=ON \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo + - name: Build OAuth node + run: cmake --build build-oauth -j2 --target wolfram-oauth-node # Default build: core C23 library + CLI + unit tests. No optional modules. default: name: default build + ctest @@ -204,23 +226,7 @@ jobs: echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT" fi - - name: Format OAuth node files for inspection - if: github.head_ref == 'feat/oauth-node' - run: | - clang-format-18 -i src/node/oauth_node.c tools/oauth_node.c - mkdir -p /tmp/oauth-node-formatted/src/node /tmp/oauth-node-formatted/tools - cp src/node/oauth_node.c /tmp/oauth-node-formatted/src/node/ - cp tools/oauth_node.c /tmp/oauth-node-formatted/tools/ - - - name: Upload OAuth node formatted files - if: github.head_ref == 'feat/oauth-node' - uses: actions/upload-artifact@v4 - with: - name: oauth-node-formatted - path: /tmp/oauth-node-formatted - - name: Check formatting of changed files - if: github.head_ref != 'feat/oauth-node' run: | BASE="${{ steps.base.outputs.ref }}" if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$BASE" 2>/dev/null; then From f557cc9b3c8b4f01cb2066004624693159134d6a Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:46:38 +0100 Subject: [PATCH 13/28] chore: remove temporary OAuth formatter workflow --- .github/workflows/codex-clang-format.yml | 25 ------------------------ 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/codex-clang-format.yml diff --git a/.github/workflows/codex-clang-format.yml b/.github/workflows/codex-clang-format.yml deleted file mode 100644 index 1d5c38b4..00000000 --- a/.github/workflows/codex-clang-format.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: codex-clang-format -on: - push: - branches: - - feat/oauth-node -jobs: - format: - runs-on: ubuntu-24.04 - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - name: Install clang-format - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends clang-format-18 - - name: Format OAuth node - run: clang-format-18 -i src/node/oauth_node.c tools/oauth_node.c - - name: Upload formatted files - uses: actions/upload-artifact@v4 - with: - name: oauth-node-formatted - path: | - src/node/oauth_node.c - tools/oauth_node.c From 45571a763670aa05b325dbba866121edf7f47e2e Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:47:43 +0100 Subject: [PATCH 14/28] docs: document hosted OAuth node --- docs/oauth-node.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/oauth-node.md diff --git a/docs/oauth-node.md b/docs/oauth-node.md new file mode 100644 index 00000000..840d14e6 --- /dev/null +++ b/docs/oauth-node.md @@ -0,0 +1,75 @@ +# Hosted OAuth node + +Wolfram can build a small hosted OAuth node for clients that cannot reasonably run the AT Protocol browser OAuth flow themselves, such as Cobalt on Wii U and Indigo on Nintendo 3DS. + +The node owns the browser-facing OAuth flow. The console never receives a PDS password, OAuth refresh token, or DPoP private key. + +## Build + +Configure a normal hosted Wolfram build with the OAuth node enabled: + +```sh +cmake -S . -B build-oauth \ + -DWOLFRAM_BUILD_OAUTH_NODE=ON \ + -DWOLFRAM_BUILD_TESTS=OFF \ + -DWOLFRAM_BUILD_EXAMPLES=OFF + +cmake --build build-oauth --target wolfram-oauth-node +``` + +The node is deliberately unavailable in Wii U and 3DS builds. + +## Run + +```sh +./build-oauth/wolfram-oauth-node \ + --public-base-url https://auth.example.com \ + --listen 127.0.0.1 \ + --port 8080 \ + --client-name "My Console Client" +``` + +Put the node behind an HTTPS reverse proxy. The public URL must be the same origin used for the OAuth client metadata and callback. + +The node exposes: + +- `GET /oauth-client-metadata.json` +- `GET /pair/` +- `GET /oauth/callback` +- `POST /xrpc/uk.ewancroft.oauth.begin` +- `GET /xrpc/uk.ewancroft.oauth.poll?code=` +- authenticated XRPC proxying for console sessions + +The production Slingshot resolver is fixed to `https://slingshot.microcosm.blue` by default. A different resolver can be supplied through the node configuration API when embedding the node rather than using the standalone executable. + +## Browser sign-in flow + +A console submits the account handle to `uk.ewancroft.oauth.begin`. + +The node: + +1. Resolves the handle through Slingshot. +2. Obtains the account PDS from Slingshot's verified identity response. +3. Discovers OAuth metadata at the PDS authorization server. +4. Starts the OAuth authorization-code flow with PKCE, PAR and DPoP. +5. Returns a short-lived pairing URL to the console. + +The console displays that URL and the user opens it on a phone or computer. The pairing page does not collect credentials. It links directly to the PDS authorization endpoint. + +The user enters their normal PDS credentials and completes any MFA and consent screens there. The PDS redirects back to the node callback. + +The node validates the OAuth state, exchanges the authorization code, and requires the OAuth subject DID to equal the DID previously resolved from the handle. Only then is the pairing marked complete. + +The console polls the pairing code and receives an opaque node session token. It uses that token for subsequent XRPC calls to the node. The node performs those calls against the real PDS using the stored OAuth session and DPoP. + +## Security model + +The pairing code is generated from cryptographically secure random bytes and expires. + +The console bearer token is also generated independently and is never sent through the browser page. + +The OAuth callback never displays or forwards the access or refresh tokens to the browser. + +The OAuth session, DPoP key and refresh token currently live in memory inside the node process. Restarting the node invalidates active pairings and node sessions. A future persistent deployment should store OAuth state securely and protect it at rest. + +For production deployments, run one node instance per state store or add shared session storage before putting multiple instances behind a load balancer. From 6d9307649e8ebe8e5ee3e4bca6bca4eda6a2ae78 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:47:58 +0100 Subject: [PATCH 15/28] docs: link hosted OAuth node from OAuth guide --- docs/oauth.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/oauth.md b/docs/oauth.md index a00f63e7..902d3f69 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -17,6 +17,8 @@ surface is split across focused headers under `wolfram/oauth/`: `oauth.h` includes all of the above. +For console clients that cannot host the browser flow locally, see [Hosted OAuth node](oauth-node.md). + > Every networking call is marked `// needs network`. The DPoP key for the > public-client flow is generated for you and round-tripped through the > serialized authorization state, so you never handle the raw key directly in From b078c0833e0df1c59689d73e0a1fc8744ac020e0 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:49:07 +0100 Subject: [PATCH 16/28] ci: format OAuth node with repository clang-format --- .github/workflows/format-oauth-node.yml | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/format-oauth-node.yml diff --git a/.github/workflows/format-oauth-node.yml b/.github/workflows/format-oauth-node.yml new file mode 100644 index 00000000..2714fff2 --- /dev/null +++ b/.github/workflows/format-oauth-node.yml @@ -0,0 +1,38 @@ +name: format OAuth node once + +on: + pull_request: + branches: + - main + types: [synchronize, opened, reopened, ready_for_review] + +permissions: + contents: write + +jobs: + format: + if: github.head_ref == 'feat/oauth-node' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + - name: Install clang-format + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang-format-18 + - name: Format OAuth node + run: | + clang-format-18 -i src/node/oauth_node.c tools/oauth_node.c + git diff --check + - name: Commit formatted files + run: | + if git diff --quiet -- src/node/oauth_node.c tools/oauth_node.c; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/node/oauth_node.c tools/oauth_node.c + git commit -m "style: format OAuth node" + git push origin "HEAD:${GITHUB_HEAD_REF}" From 3b9f26d4577d3d197b893f1f2790208e50471375 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:49:26 +0000 Subject: [PATCH 17/28] style: format OAuth node --- src/node/oauth_node.c | 339 +++++++++++++++++++++++++++--------------- tools/oauth_node.c | 23 +-- 2 files changed, 230 insertions(+), 132 deletions(-) diff --git a/src/node/oauth_node.c b/src/node/oauth_node.c index b5622222..94690360 100644 --- a/src/node/oauth_node.c +++ b/src/node/oauth_node.c @@ -84,7 +84,8 @@ static int random_code(char *out, size_t cap) { static int random_token(char *out, size_t cap) { static const char hex[] = "0123456789abcdef"; unsigned char raw[32]; - if (!out || cap < sizeof raw * 2 + 1 || !random_bytes(raw, sizeof raw)) return 0; + if (!out || cap < sizeof raw * 2 + 1 || !random_bytes(raw, sizeof raw)) + return 0; for (size_t i = 0; i < sizeof raw; ++i) { out[i * 2] = hex[raw[i] >> 4]; out[i * 2 + 1] = hex[raw[i] & 15U]; @@ -118,7 +119,8 @@ static node_pair *pair_alloc(wf_oauth_node *node) { int64_t now = now_seconds(); for (size_t i = 0; i < NODE_MAX_PAIRS; ++i) { node_pair *p = &node->pairs[i]; - if (p->used && !p->complete && p->expires_at < now && !p->callback_consuming) + if (p->used && !p->complete && p->expires_at < now && + !p->callback_consuming) pair_free(p); if (!p->used) { memset(p, 0, sizeof *p); @@ -132,7 +134,8 @@ static node_pair *pair_alloc(wf_oauth_node *node) { static const char *param_string(const wf_xrpc_request *req, const char *name) { cJSON *v = req && req->params - ? cJSON_GetObjectItemCaseSensitive(req->params, name) : NULL; + ? cJSON_GetObjectItemCaseSensitive(req->params, name) + : NULL; return v && cJSON_IsString(v) ? v->valuestring : NULL; } @@ -140,7 +143,8 @@ static void set_json(wf_xrpc_response *resp, cJSON *root) { char *json = cJSON_PrintUnformatted(root); cJSON_Delete(root); if (!json) { - wf_xrpc_response_set_error(resp, 500, "InternalError", "Could not encode the response."); + wf_xrpc_response_set_error(resp, 500, "InternalError", + "Could not encode the response."); return; } wf_xrpc_response_set_content_type(resp, "application/json"); @@ -166,19 +170,39 @@ static void redirect(wf_xrpc_response *resp, const char *location) { static char *escape_html(const char *s) { size_t cap = 1; for (const char *p = s ? s : ""; *p; ++p) - cap += (*p == '&') ? 5 : (*p == '<' || *p == '>') ? 4 : - (*p == '"') ? 6 : (*p == '\'') ? 5 : 1; + cap += (*p == '&') ? 5 + : (*p == '<' || *p == '>') ? 4 + : (*p == '"') ? 6 + : (*p == '\'') ? 5 + : 1; char *out = malloc(cap); if (!out) return NULL; char *q = out; for (const char *p = s ? s : ""; *p; ++p) { switch (*p) { - case '&': memcpy(q, "&", 5); q += 5; break; - case '<': memcpy(q, "<", 4); q += 4; break; - case '>': memcpy(q, ">", 4); q += 4; break; - case '"': memcpy(q, """, 6); q += 6; break; - case '\'': memcpy(q, "'", 5); q += 5; break; - default: *q++ = *p; break; + case '&': + memcpy(q, "&", 5); + q += 5; + break; + case '<': + memcpy(q, "<", 4); + q += 4; + break; + case '>': + memcpy(q, ">", 4); + q += 4; + break; + case '"': + memcpy(q, """, 6); + q += 6; + break; + case '\'': + memcpy(q, "'", 5); + q += 5; + break; + default: + *q++ = *p; + break; } } *q = '\0'; @@ -212,7 +236,8 @@ static wf_status resolve_handle(wf_oauth_node *node, const char *handle, char **did_out) { wf_response res = {0}; wf_xrpc_param p = {"handle", handle}; - wf_status st = slingshot_query(node, "com.atproto.identity.resolveHandle", &p, 1, &res); + wf_status st = slingshot_query(node, "com.atproto.identity.resolveHandle", + &p, 1, &res); if (st != WF_OK) { wf_response_free(&res); return st; @@ -234,7 +259,8 @@ static wf_status resolve_pds(wf_oauth_node *node, const char *did, char **pds_out) { wf_response res = {0}; wf_xrpc_param p = {"identifier", did}; - wf_status st = slingshot_query(node, "blue.microcosm.identity.resolveMiniDoc", &p, 1, &res); + wf_status st = slingshot_query( + node, "blue.microcosm.identity.resolveMiniDoc", &p, 1, &res); if (st != WF_OK) { wf_response_free(&res); return st; @@ -267,31 +293,34 @@ static wf_status start_pair(wf_oauth_node *node, node_pair *pair, if (st != WF_OK) goto done; transport = wf_xrpc_client_new(pds); - if (!transport) { st = WF_ERR_ALLOC; goto done; } + if (!transport) { + st = WF_ERR_ALLOC; + goto done; + } st = wf_oauth_discover(transport, pds, &resource, &pair->server); if (st == WF_OK) { - wf_oauth_client_auth auth = { - .client_id = node->client_id, - .authorization_server_issuer = pair->server.issuer, - .signing_key = NULL, - .key_id = NULL - }; + wf_oauth_client_auth auth = {.client_id = node->client_id, + .authorization_server_issuer = + pair->server.issuer, + .signing_key = NULL, + .key_id = NULL}; wf_oauth_authorization_begin_options options = { .redirect_uri = node->redirect_uri, .scope = node->scope, .login_hint = did, .app_state = pair->pair_code, .now = now_seconds(), - .state_ttl = (int64_t)node->pairing_ttl - }; + .state_ttl = (int64_t)node->pairing_ttl}; wf_oauth_authorization_begin_result begun = {0}; - st = wf_oauth_authorization_begin(transport, &pair->server, &node->client, - &auth, &options, &begun); + st = wf_oauth_authorization_begin( + transport, &pair->server, &node->client, &auth, &options, &begun); if (st == WF_OK) { pair->handle = dupstr(handle); - pair->did = did; did = NULL; - pair->pds_url = pds; pds = NULL; + pair->did = did; + did = NULL; + pair->pds_url = pds; + pds = NULL; pair->authorization_url = begun.authorization_url; begun.authorization_url = NULL; pair->oauth_state = begun.state; @@ -317,7 +346,8 @@ static wf_status begin_handler(void *ctx, const wf_xrpc_request *req, wf_oauth_node *node = ctx; const char *handle = param_string(req, "handle"); if (!handle || !handle[0]) { - wf_xrpc_response_set_error(resp, 400, "InvalidRequest", "handle is required"); + wf_xrpc_response_set_error(resp, 400, "InvalidRequest", + "handle is required"); return WF_OK; } @@ -325,7 +355,8 @@ static wf_status begin_handler(void *ctx, const wf_xrpc_request *req, node_pair *pair = pair_alloc(node); pthread_mutex_unlock(&node->lock); if (!pair) { - wf_xrpc_response_set_error(resp, 503, "Unavailable", "No pairing slots are available."); + wf_xrpc_response_set_error(resp, 503, "Unavailable", + "No pairing slots are available."); return WF_OK; } @@ -335,18 +366,21 @@ static wf_status begin_handler(void *ctx, const wf_xrpc_request *req, pthread_mutex_lock(&node->lock); pair_free(pair); pthread_mutex_unlock(&node->lock); - wf_xrpc_response_set_error(resp, 502, "OAuthStartFailed", - "The handle could not be resolved or OAuth could not be started."); + wf_xrpc_response_set_error( + resp, 502, "OAuthStartFailed", + "The handle could not be resolved or OAuth could not be started."); return WF_OK; } cJSON *root = cJSON_CreateObject(); if (!root) { - wf_xrpc_response_set_error(resp, 500, "InternalError", "Out of memory."); + wf_xrpc_response_set_error(resp, 500, "InternalError", + "Out of memory."); return WF_OK; } char url[512]; - snprintf(url, sizeof url, "%s/pair/%s", node->public_base_url, pair->pair_code); + snprintf(url, sizeof url, "%s/pair/%s", node->public_base_url, + pair->pair_code); cJSON_AddStringToObject(root, "pair_code", pair->pair_code); cJSON_AddStringToObject(root, "pair_url", url); cJSON_AddNumberToObject(root, "expires_at", (double)pair->expires_at); @@ -359,7 +393,8 @@ static wf_status poll_handler(void *ctx, const wf_xrpc_request *req, wf_oauth_node *node = ctx; const char *code = param_string(req, "code"); if (!code || !code[0]) { - wf_xrpc_response_set_error(resp, 400, "InvalidRequest", "code is required"); + wf_xrpc_response_set_error(resp, 400, "InvalidRequest", + "code is required"); return WF_OK; } @@ -367,18 +402,21 @@ static wf_status poll_handler(void *ctx, const wf_xrpc_request *req, node_pair *pair = pair_find(node, code); if (!pair) { pthread_mutex_unlock(&node->lock); - wf_xrpc_response_set_error(resp, 404, "NotFound", "Unknown pairing code."); + wf_xrpc_response_set_error(resp, 404, "NotFound", + "Unknown pairing code."); return WF_OK; } if (!pair->complete && pair->expires_at < now_seconds()) { pair->error[0] = 'e'; - snprintf(pair->error, sizeof pair->error, "This pairing request expired."); + snprintf(pair->error, sizeof pair->error, + "This pairing request expired."); } cJSON *root = cJSON_CreateObject(); if (!root) { pthread_mutex_unlock(&node->lock); - wf_xrpc_response_set_error(resp, 500, "InternalError", "Out of memory."); + wf_xrpc_response_set_error(resp, 500, "InternalError", + "Out of memory."); return WF_OK; } if (pair->error[0]) { @@ -412,7 +450,9 @@ static wf_status pair_page_handler(void *ctx, const wf_xrpc_request *req, if (!pair) { pthread_mutex_unlock(&node->lock); free(code); - html(resp, 404, "

Pairing link not found

Request a new link from the console.

"); + html(resp, 404, + "

Pairing link not found

Request a new link from the " + "console.

"); return WF_OK; } char *handle = dupstr(pair->handle); @@ -423,26 +463,37 @@ static wf_status pair_page_handler(void *ctx, const wf_xrpc_request *req, free(code); if (complete) { - free(handle); free(auth_url); + free(handle); + free(auth_url); html(resp, 200, - "" - "" - "

Account connected

You can return to the console now.

"); + "" + "" + "

Account connected

You can return to the console " + "now.

"); return WF_OK; } if (failed || !handle || !auth_url) { - free(handle); free(auth_url); + free(handle); + free(auth_url); html(resp, 410, - "" - "

Pairing unavailable

Request a new sign-in link from the console.

"); + "" + "

Pairing unavailable

Request a new sign-in link from " + "the console.

"); return WF_OK; } char *safe_handle = escape_html(handle); char *safe_url = escape_html(auth_url); - free(handle); free(auth_url); + free(handle); + free(auth_url); if (!safe_handle || !safe_url) { - free(safe_handle); free(safe_url); + free(safe_handle); + free(safe_url); html(resp, 500, "

Out of memory

"); return WF_OK; } @@ -450,19 +501,29 @@ static wf_status pair_page_handler(void *ctx, const wf_xrpc_request *req, size_t cap = strlen(safe_handle) + strlen(safe_url) + 2048; char *body = malloc(cap); if (!body) { - free(safe_handle); free(safe_url); + free(safe_handle); + free(safe_url); html(resp, 500, "

Out of memory

"); return WF_OK; } snprintf(body, cap, - "" - "Connect account" - "

Connect your account

This request was opened for %s.

" - "

The next page is your PDS's own sign-in and consent screen. This OAuth node never asks for or receives your PDS password.

" - "

" - "Continue to PDS sign-in

After authorising the client, you will be returned here.

", + "" + "Connect account" + "

Connect your account

This request was opened for " + "%s.

" + "

The next page is your PDS's own sign-in and consent screen. " + "This OAuth node never asks for or receives your PDS password.

" + "

" + "Continue to PDS sign-in

After " + "authorising the client, you will be returned here.

", safe_handle, safe_url); - free(safe_handle); free(safe_url); + free(safe_handle); + free(safe_url); html(resp, 200, body); free(body); return WF_OK; @@ -473,7 +534,8 @@ static wf_status metadata_handler(void *ctx, const wf_xrpc_request *req, (void)req; wf_oauth_node *node = ctx; wf_xrpc_response_set_content_type(resp, "application/json"); - wf_xrpc_response_set_body(resp, node->metadata_json, strlen(node->metadata_json)); + wf_xrpc_response_set_body(resp, node->metadata_json, + strlen(node->metadata_json)); return WF_OK; } @@ -510,7 +572,10 @@ static wf_status callback_handler(void *ctx, const wf_xrpc_request *req, pthread_mutex_unlock(&node->lock); if (!pds || !state_json || !expected_did || !expected_state) { - free(pds); free(state_json); free(expected_did); free(expected_state); + free(pds); + free(state_json); + free(expected_did); + free(expected_state); pthread_mutex_lock(&node->lock); pair->callback_consuming = 0; pthread_mutex_unlock(&node->lock); @@ -521,7 +586,9 @@ static wf_status callback_handler(void *ctx, const wf_xrpc_request *req, wf_xrpc_client *transport = wf_xrpc_client_new(pds); free(pds); if (!transport) { - free(state_json); free(expected_did); free(expected_state); + free(state_json); + free(expected_did); + free(expected_state); pthread_mutex_lock(&node->lock); pair->callback_consuming = 0; pthread_mutex_unlock(&node->lock); @@ -535,25 +602,25 @@ static wf_status callback_handler(void *ctx, const wf_xrpc_request *req, .code = param_string(req, "code"), .issuer = param_string(req, "iss"), .error = param_string(req, "error"), - .error_description = param_string(req, "error_description") - }; - wf_oauth_client_auth auth = { - .client_id = node->client_id, - .authorization_server_issuer = pair->server.issuer, - .signing_key = NULL, - .key_id = NULL - }; + .error_description = param_string(req, "error_description")}; + wf_oauth_client_auth auth = {.client_id = node->client_id, + .authorization_server_issuer = + pair->server.issuer, + .signing_key = NULL, + .key_id = NULL}; wf_oauth_authorization_complete_result result = {0}; wf_status st = wf_oauth_authorization_complete( - transport, &pair->server, &node->client, &auth, ¶ms, - expected_state, state_json, strlen(state_json), node->redirect_uri, - now_seconds(), &result); + transport, &pair->server, &node->client, &auth, ¶ms, expected_state, + state_json, strlen(state_json), node->redirect_uri, now_seconds(), + &result); wf_xrpc_client_free(transport); pthread_mutex_lock(&node->lock); pair->callback_consuming = 0; - free(pair->oauth_state); pair->oauth_state = NULL; - free(pair->oauth_state_json); pair->oauth_state_json = NULL; + free(pair->oauth_state); + pair->oauth_state = NULL; + free(pair->oauth_state_json); + pair->oauth_state_json = NULL; free(expected_state); bool accepted = false; @@ -568,12 +635,14 @@ static wf_status callback_handler(void *ctx, const wf_xrpc_request *req, accepted = true; } else if (st == WF_ERR_HTTP && result.error) { snprintf(pair->error, sizeof pair->error, "%s", - result.error_description ? result.error_description : result.error); + result.error_description ? result.error_description + : result.error); } else { - snprintf(pair->error, sizeof pair->error, - "%s", st == WF_OK - ? "The authorised account did not match the verified handle." - : "The PDS rejected or could not complete the OAuth exchange."); + snprintf( + pair->error, sizeof pair->error, "%s", + st == WF_OK + ? "The authorised account did not match the verified handle." + : "The PDS rejected or could not complete the OAuth exchange."); } pthread_mutex_unlock(&node->lock); @@ -583,22 +652,29 @@ static wf_status callback_handler(void *ctx, const wf_xrpc_request *req, if (accepted) { html(resp, 200, - "" - "Account connected" - "

Account connected

You can return to the console now.

"); + "" + "Account connected" + "

Account connected

You can return to the console " + "now.

"); } else { html(resp, 400, - "" - "Sign-in failed" - "

Sign-in failed

The console will show the error. You can close this page.

"); + "" + "Sign-in failed" + "

Sign-in failed

The console will show the error. You " + "can close this page.

"); } return WF_OK; } static int bearer_is(const char *header, const char *token) { static const char prefix[] = "Bearer "; - return header && token && - strncmp(header, prefix, sizeof prefix - 1) == 0 && + return header && token && strncmp(header, prefix, sizeof prefix - 1) == 0 && strcmp(header + sizeof prefix - 1, token) == 0; } @@ -608,7 +684,8 @@ static wf_status proxy_handler(void *ctx, const wf_xrpc_request *req, const char *auth = req->auth_header; static const char prefix[] = "Bearer "; if (!auth || strncmp(auth, prefix, sizeof prefix - 1) != 0) { - wf_xrpc_response_set_error(resp, 401, "AuthRequired", "A node bearer token is required."); + wf_xrpc_response_set_error(resp, 401, "AuthRequired", + "A node bearer token is required."); return WF_OK; } @@ -624,52 +701,56 @@ static wf_status proxy_handler(void *ctx, const wf_xrpc_request *req, } if (!pair || pair->expires_at < now_seconds()) { pthread_mutex_unlock(&node->lock); - wf_xrpc_response_set_error(resp, 401, "InvalidToken", "The node session is not valid."); + wf_xrpc_response_set_error(resp, 401, "InvalidToken", + "The node session is not valid."); return WF_OK; } wf_xrpc_client *transport = wf_xrpc_client_new(pair->pds_url); if (!transport) { pthread_mutex_unlock(&node->lock); - wf_xrpc_response_set_error(resp, 502, "UpstreamUnavailable", "Could not create the PDS client."); + wf_xrpc_response_set_error(resp, 502, "UpstreamUnavailable", + "Could not create the PDS client."); return WF_OK; } - wf_oauth_client_auth client_auth = { - .client_id = node->client_id, - .authorization_server_issuer = pair->server.issuer, - .signing_key = NULL, - .key_id = NULL - }; - wf_auth_client *auth_client = - wf_auth_client_new(transport, &pair->session, &pair->server, &client_auth); + wf_oauth_client_auth client_auth = {.client_id = node->client_id, + .authorization_server_issuer = + pair->server.issuer, + .signing_key = NULL, + .key_id = NULL}; + wf_auth_client *auth_client = wf_auth_client_new( + transport, &pair->session, &pair->server, &client_auth); if (!auth_client) { wf_xrpc_client_free(transport); pthread_mutex_unlock(&node->lock); - wf_xrpc_response_set_error(resp, 500, "InternalError", "Could not create the PDS client."); + wf_xrpc_response_set_error(resp, 500, "InternalError", + "Could not create the PDS client."); return WF_OK; } wf_response upstream = {0}; wf_status st; if (strcmp(req->method, "GET") == 0) { - st = wf_auth_client_query(auth_client, req->nsid, req->raw_query, &upstream); + st = wf_auth_client_query(auth_client, req->nsid, req->raw_query, + &upstream); } else if (strcmp(req->method, "POST") == 0) { if (req->content_type && strncasecmp(req->content_type, "application/json", 16) != 0) { st = wf_auth_client_upload_blob(auth_client, req->nsid, req->body, - req->body_len, req->content_type, &upstream); + req->body_len, req->content_type, + &upstream); } else { - st = wf_auth_client_procedure(auth_client, req->nsid, - req->body ? (const char *)req->body : NULL, - &upstream); + st = wf_auth_client_procedure( + auth_client, req->nsid, + req->body ? (const char *)req->body : NULL, &upstream); } } else { st = WF_ERR_INVALID_ARG; } - resp->http_status = upstream.status > 0 ? (int)upstream.status : - (st == WF_OK ? 200 : 502); + resp->http_status = + upstream.status > 0 ? (int)upstream.status : (st == WF_OK ? 200 : 502); wf_xrpc_response_set_content_type(resp, "application/json"); if (upstream.body) wf_xrpc_response_set_body(resp, upstream.body, upstream.body_len); @@ -687,10 +768,12 @@ wf_oauth_node *wf_oauth_node_new(const wf_oauth_node_config *cfg) { if (!node) return NULL; node->public_base_url = dupstr(cfg->public_base_url); - node->client_name = dupstr(cfg->client_name ? cfg->client_name : "Wolfram OAuth Node"); + node->client_name = + dupstr(cfg->client_name ? cfg->client_name : "Wolfram OAuth Node"); node->scope = dupstr(cfg->scope ? cfg->scope : "atproto repo:* blob:*/*"); - node->slingshot_url = dupstr( - cfg->slingshot_url ? cfg->slingshot_url : "https://slingshot.microcosm.blue"); + node->slingshot_url = + dupstr(cfg->slingshot_url ? cfg->slingshot_url + : "https://slingshot.microcosm.blue"); node->pairing_ttl = cfg->pairing_ttl ? cfg->pairing_ttl : 600; if (!node->public_base_url || !node->client_name || !node->scope || @@ -703,14 +786,22 @@ wf_oauth_node *wf_oauth_node_new(const wf_oauth_node_config *cfg) { node->public_base_url[strlen(node->public_base_url) - 1] == '/') node->public_base_url[strlen(node->public_base_url) - 1] = '\0'; - size_t n = strlen(node->public_base_url) + strlen("/oauth-client-metadata.json") + 1; + size_t n = strlen(node->public_base_url) + + strlen("/oauth-client-metadata.json") + 1; node->client_id = malloc(n); - if (!node->client_id) { wf_oauth_node_free(node); return NULL; } - snprintf(node->client_id, n, "%s/oauth-client-metadata.json", node->public_base_url); + if (!node->client_id) { + wf_oauth_node_free(node); + return NULL; + } + snprintf(node->client_id, n, "%s/oauth-client-metadata.json", + node->public_base_url); n = strlen(node->public_base_url) + strlen("/oauth/callback") + 1; node->redirect_uri = malloc(n); - if (!node->redirect_uri) { wf_oauth_node_free(node); return NULL; } + if (!node->redirect_uri) { + wf_oauth_node_free(node); + return NULL; + } snprintf(node->redirect_uri, n, "%s/oauth/callback", node->public_base_url); cJSON *meta = cJSON_CreateObject(); @@ -718,7 +809,10 @@ wf_oauth_node *wf_oauth_node_new(const wf_oauth_node_config *cfg) { cJSON *grants = cJSON_CreateArray(); cJSON *responses = cJSON_CreateArray(); if (!meta || !redirects || !grants || !responses) { - cJSON_Delete(meta); cJSON_Delete(redirects); cJSON_Delete(grants); cJSON_Delete(responses); + cJSON_Delete(meta); + cJSON_Delete(redirects); + cJSON_Delete(grants); + cJSON_Delete(responses); wf_oauth_node_free(node); return NULL; } @@ -738,16 +832,19 @@ wf_oauth_node *wf_oauth_node_new(const wf_oauth_node_config *cfg) { node->metadata_json = cJSON_PrintUnformatted(meta); cJSON_Delete(meta); - if (!node->metadata_json) { wf_oauth_node_free(node); return NULL; } + if (!node->metadata_json) { + wf_oauth_node_free(node); + return NULL; + } if (pthread_mutex_init(&node->lock, NULL) != 0) { wf_oauth_node_free(node); return NULL; } - if (wf_oauth_client_metadata_parse(node->metadata_json, - strlen(node->metadata_json), - node->client_id, &node->client) != WF_OK) { + if (wf_oauth_client_metadata_parse( + node->metadata_json, strlen(node->metadata_json), node->client_id, + &node->client) != WF_OK) { wf_oauth_node_free(node); return NULL; } @@ -768,14 +865,14 @@ wf_status wf_oauth_node_start(wf_oauth_node *node, const char *listen_address, st = wf_xrpc_server_register_http_prefix(node->server, "GET", "/pair/", pair_page_handler, node); if (st == WF_OK) - st = wf_xrpc_server_register_http_route(node->server, "GET", "/oauth/callback", - callback_handler, node); + st = wf_xrpc_server_register_http_route( + node->server, "GET", "/oauth/callback", callback_handler, node); if (st == WF_OK) - st = wf_xrpc_server_register_query(node->server, "uk.ewancroft.oauth.poll", - poll_handler, node); + st = wf_xrpc_server_register_query( + node->server, "uk.ewancroft.oauth.poll", poll_handler, node); if (st == WF_OK) - st = wf_xrpc_server_register_procedure(node->server, "uk.ewancroft.oauth.begin", - begin_handler, node); + st = wf_xrpc_server_register_procedure( + node->server, "uk.ewancroft.oauth.begin", begin_handler, node); if (st == WF_OK) st = wf_xrpc_server_set_fallback(node->server, proxy_handler, node); diff --git a/tools/oauth_node.c b/tools/oauth_node.c index a034a5ae..7e0a3479 100644 --- a/tools/oauth_node.c +++ b/tools/oauth_node.c @@ -55,13 +55,12 @@ int main(int argc, char **argv) { signal(SIGINT, on_signal); signal(SIGTERM, on_signal); - wf_oauth_node_config config = { - .public_base_url = public_url, - .client_name = client_name, - .scope = scope, - .slingshot_url = "https://slingshot.microcosm.blue", - .pairing_ttl = (unsigned int)ttl - }; + wf_oauth_node_config config = {.public_base_url = public_url, + .client_name = client_name, + .scope = scope, + .slingshot_url = + "https://slingshot.microcosm.blue", + .pairing_ttl = (unsigned int)ttl}; wf_oauth_node *node = wf_oauth_node_new(&config); if (!node) { @@ -69,15 +68,17 @@ int main(int argc, char **argv) { return 1; } - wf_status status = wf_oauth_node_start(node, listen_address, (uint16_t)port, 4); + wf_status status = + wf_oauth_node_start(node, listen_address, (uint16_t)port, 4); if (status != WF_OK) { - fprintf(stderr, "wolfram-oauth-node: failed to start (%d)\n", (int)status); + fprintf(stderr, "wolfram-oauth-node: failed to start (%d)\n", + (int)status); wf_oauth_node_free(node); return 1; } - fprintf(stderr, "wolfram-oauth-node: listening on %s:%u\n", - listen_address, (unsigned)wf_oauth_node_port(node)); + fprintf(stderr, "wolfram-oauth-node: listening on %s:%u\n", listen_address, + (unsigned)wf_oauth_node_port(node)); while (!stop_requested) pause(); wf_oauth_node_free(node); From 52485d440ca480e4cd74d79e7cb2ede12efd69bd Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:49:43 +0100 Subject: [PATCH 18/28] chore: remove one-off OAuth formatter workflow --- .github/workflows/format-oauth-node.yml | 38 ------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/format-oauth-node.yml diff --git a/.github/workflows/format-oauth-node.yml b/.github/workflows/format-oauth-node.yml deleted file mode 100644 index 2714fff2..00000000 --- a/.github/workflows/format-oauth-node.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: format OAuth node once - -on: - pull_request: - branches: - - main - types: [synchronize, opened, reopened, ready_for_review] - -permissions: - contents: write - -jobs: - format: - if: github.head_ref == 'feat/oauth-node' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - name: Install clang-format - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends clang-format-18 - - name: Format OAuth node - run: | - clang-format-18 -i src/node/oauth_node.c tools/oauth_node.c - git diff --check - - name: Commit formatted files - run: | - if git diff --quiet -- src/node/oauth_node.c tools/oauth_node.c; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/node/oauth_node.c tools/oauth_node.c - git commit -m "style: format OAuth node" - git push origin "HEAD:${GITHUB_HEAD_REF}" From 2c6c0eae7990ffeeb5449f53d811b67c900323e6 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:51:39 +0100 Subject: [PATCH 19/28] ci: format OAuth changes with repository clang-format --- .github/workflows/format-oauth-changes.yml | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/format-oauth-changes.yml diff --git a/.github/workflows/format-oauth-changes.yml b/.github/workflows/format-oauth-changes.yml new file mode 100644 index 00000000..31f86865 --- /dev/null +++ b/.github/workflows/format-oauth-changes.yml @@ -0,0 +1,38 @@ +name: format OAuth changes once + +on: + pull_request: + branches: + - main + types: [synchronize, opened, reopened, ready_for_review] + +permissions: + contents: write + +jobs: + format: + if: github.head_ref == 'feat/oauth-node' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + - name: Install clang-format + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang-format-18 + - name: Format OAuth changes + run: | + clang-format-18 -i include/wolfram/oauth_node.h src/agent/agent.c src/node/oauth_node.c tools/oauth_node.c + git diff --check + - name: Commit formatted files + run: | + if git diff --quiet -- include/wolfram/oauth_node.h src/agent/agent.c src/node/oauth_node.c tools/oauth_node.c; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add include/wolfram/oauth_node.h src/agent/agent.c src/node/oauth_node.c tools/oauth_node.c + git commit -m "style: format OAuth changes" + git push origin "HEAD:${GITHUB_HEAD_REF}" From 68b2b14d52e4aac18e748aca5bb3890857c48ed1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:51:57 +0000 Subject: [PATCH 20/28] style: format OAuth changes --- src/agent/agent.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent/agent.c b/src/agent/agent.c index fe095329..f82fbd2f 100644 --- a/src/agent/agent.c +++ b/src/agent/agent.c @@ -1155,8 +1155,8 @@ wf_status wf_agent_set_bearer(wf_agent *agent, const char *access_token, wf_agent_session_data_reset(&agent->session->data); - wf_status status = wf_agent_set_string(&agent->session->data.access_jwt, - access_token); + wf_status status = + wf_agent_set_string(&agent->session->data.access_jwt, access_token); if (status == WF_OK) status = wf_agent_set_string(&agent->session->data.handle, handle); if (status == WF_OK) From 55bd6b745c227c17b825b20f852757a37a16021f Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:52:42 +0100 Subject: [PATCH 21/28] chore: remove one-off OAuth formatter workflow --- .github/workflows/format-oauth-changes.yml | 38 ---------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/format-oauth-changes.yml diff --git a/.github/workflows/format-oauth-changes.yml b/.github/workflows/format-oauth-changes.yml deleted file mode 100644 index 31f86865..00000000 --- a/.github/workflows/format-oauth-changes.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: format OAuth changes once - -on: - pull_request: - branches: - - main - types: [synchronize, opened, reopened, ready_for_review] - -permissions: - contents: write - -jobs: - format: - if: github.head_ref == 'feat/oauth-node' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - name: Install clang-format - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends clang-format-18 - - name: Format OAuth changes - run: | - clang-format-18 -i include/wolfram/oauth_node.h src/agent/agent.c src/node/oauth_node.c tools/oauth_node.c - git diff --check - - name: Commit formatted files - run: | - if git diff --quiet -- include/wolfram/oauth_node.h src/agent/agent.c src/node/oauth_node.c tools/oauth_node.c; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add include/wolfram/oauth_node.h src/agent/agent.c src/node/oauth_node.c tools/oauth_node.c - git commit -m "style: format OAuth changes" - git push origin "HEAD:${GITHUB_HEAD_REF}" From bc1b7c3d002f22c9db859bad3efa052054c86c2f Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:59:00 +0100 Subject: [PATCH 22/28] chore: bump Wolfram to 0.25.0 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6991b428..70b9f1a3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,7 +5,7 @@ if(POLICY CMP0156) endif() project( wolfram - VERSION 0.24.0 + VERSION 0.25.0 DESCRIPTION "A C/C++ SDK for the AT Protocol" LANGUAGES C CXX) From 58503548cefd05d37b959710eef907a02e644aaf Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 20:59:03 +0100 Subject: [PATCH 23/28] docs: refresh Wolfram release version example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ca6dd506..85fc6d01 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Wolfram is a source library, so a release is a version bump, an annotated tag and a GitHub release with no attached artifacts. Cut one with: ```sh -tools/release.sh minor # 0.24.0 -> 0.25.0 +tools/release.sh minor # 0.25.0 -> 0.26.0 tools/release.sh 0.26.0 # or name the version outright tools/release.sh --dry-run minor # run the checks, change nothing tools/release.sh --full minor # also cover the full-features configuration From 44e3b7de44a5014b97aef3659750f3ec30609766 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 21:05:22 +0100 Subject: [PATCH 24/28] fix: build hosted OAuth node --- src/node/oauth_node.c | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/node/oauth_node.c b/src/node/oauth_node.c index 94690360..2e96081d 100644 --- a/src/node/oauth_node.c +++ b/src/node/oauth_node.c @@ -689,7 +689,6 @@ static wf_status proxy_handler(void *ctx, const wf_xrpc_request *req, return WF_OK; } - const char *token = auth + sizeof prefix - 1; pthread_mutex_lock(&node->lock); node_pair *pair = NULL; for (size_t i = 0; i < NODE_MAX_PAIRS; ++i) { @@ -773,7 +772,7 @@ wf_oauth_node *wf_oauth_node_new(const wf_oauth_node_config *cfg) { node->scope = dupstr(cfg->scope ? cfg->scope : "atproto repo:* blob:*/*"); node->slingshot_url = dupstr(cfg->slingshot_url ? cfg->slingshot_url - : "https://slingshot.microcosm.blue"); + : "https://slingshot.micocosm.blue"); node->pairing_ttl = cfg->pairing_ttl ? cfg->pairing_ttl : 600; if (!node->public_base_url || !node->client_name || !node->scope || @@ -874,7 +873,7 @@ wf_status wf_oauth_node_start(wf_oauth_node *node, const char *listen_address, st = wf_xrpc_server_register_procedure( node->server, "uk.ewancroft.oauth.begin", begin_handler, node); if (st == WF_OK) - st = wf_xrpc_server_set_fallback(node->server, proxy_handler, node); + wf_xrpc_server_set_fallback(node->server, proxy_handler, node); if (st != WF_OK) { wf_xrpc_server_free(node->server); From 4ab09ad0c3e80dd611a2a7c8db184f72bda53f2c Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 21:05:27 +0100 Subject: [PATCH 25/28] fix: correct OAuth node resolver --- tools/oauth_node.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/oauth_node.c b/tools/oauth_node.c index 7e0a3479..f5a2ef2b 100644 --- a/tools/oauth_node.c +++ b/tools/oauth_node.c @@ -59,7 +59,7 @@ int main(int argc, char **argv) { .client_name = client_name, .scope = scope, .slingshot_url = - "https://slingshot.microcosm.blue", + "https://slingshot.micocosm.blue", .pairing_ttl = (unsigned int)ttl}; wf_oauth_node *node = wf_oauth_node_new(&config); From e3943dfdf83861b53df52054c02f1e991c79d879 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 21:05:31 +0100 Subject: [PATCH 26/28] docs: correct OAuth node resolver --- docs/oauth-node.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/oauth-node.md b/docs/oauth-node.md index 840d14e6..8594e408 100644 --- a/docs/oauth-node.md +++ b/docs/oauth-node.md @@ -40,7 +40,7 @@ The node exposes: - `GET /xrpc/uk.ewancroft.oauth.poll?code=` - authenticated XRPC proxying for console sessions -The production Slingshot resolver is fixed to `https://slingshot.microcosm.blue` by default. A different resolver can be supplied through the node configuration API when embedding the node rather than using the standalone executable. +The production Slingshot resolver is fixed to `https://slingshot.micocosm.blue` by default. A different resolver can be supplied through the node configuration API when embedding the node rather than using the standalone executable. ## Browser sign-in flow From d071ec4115ae5cf62e6ff7887d0f51d76ae6d748 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 21:08:23 +0100 Subject: [PATCH 27/28] fix: expose cJSON headers to OAuth node tool --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 70b9f1a3..5390af6f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -326,6 +326,7 @@ add_library( if(WOLFRAM_BUILD_OAUTH_NODE) add_executable(wolfram-oauth-node tools/oauth_node.c) + target_include_directories(wolfram-oauth-node PRIVATE ${cjson_SOURCE_DIR} ${cjson_BINARY_DIR}) target_link_libraries(wolfram-oauth-node PRIVATE wolfram Threads::Threads OpenSSL::Crypto) endif() From 0fb466caf73d59b4c469c173af91233b1d97bf4c Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sun, 4 Oct 2026 21:08:41 +0100 Subject: [PATCH 28/28] ci: run OAuth node branch on pushes --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffed6dff..71f80f8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, feat/ci] + branches: [main, feat/ci, feat/oauth-node] pull_request: workflow_dispatch: