Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b61b704
feat: add OAuth node public API
ewanc26 Oct 4, 2026
4b25858
feat: implement unified OAuth pairing node
ewanc26 Oct 4, 2026
717037b
feat: allow agents to use externally managed bearer tokens
ewanc26 Oct 4, 2026
f800886
feat: support external bearer-backed agents
ewanc26 Oct 4, 2026
1645c07
build: add hosted OAuth node target
ewanc26 Oct 4, 2026
b083222
feat: add OAuth node executable
ewanc26 Oct 4, 2026
06deaaa
build: define OAuth node target after library
ewanc26 Oct 4, 2026
9406baa
fix: reject OAuth subject mismatches in callback
ewanc26 Oct 4, 2026
9c05189
ci: add temporary OAuth node formatter
ewanc26 Oct 4, 2026
308a2a7
ci: emit formatter output for OAuth node PR
ewanc26 Oct 4, 2026
38c7be5
ci: preserve formatted OAuth node paths in artifact
ewanc26 Oct 4, 2026
8a640de
ci: build hosted OAuth node
ewanc26 Oct 4, 2026
f557cc9
chore: remove temporary OAuth formatter workflow
ewanc26 Oct 4, 2026
45571a7
docs: document hosted OAuth node
ewanc26 Oct 4, 2026
6d93076
docs: link hosted OAuth node from OAuth guide
ewanc26 Oct 4, 2026
b078c08
ci: format OAuth node with repository clang-format
ewanc26 Oct 4, 2026
3b9f26d
style: format OAuth node
github-actions[bot] Oct 4, 2026
52485d4
chore: remove one-off OAuth formatter workflow
ewanc26 Oct 4, 2026
2c6c0ea
ci: format OAuth changes with repository clang-format
ewanc26 Oct 4, 2026
68b2b14
style: format OAuth changes
github-actions[bot] Oct 4, 2026
55bd6b7
chore: remove one-off OAuth formatter workflow
ewanc26 Oct 4, 2026
bc1b7c3
chore: bump Wolfram to 0.25.0
ewanc26 Oct 4, 2026
5850354
docs: refresh Wolfram release version example
ewanc26 Oct 4, 2026
44e3b7d
fix: build hosted OAuth node
ewanc26 Oct 4, 2026
4ab09ad
fix: correct OAuth node resolver
ewanc26 Oct 4, 2026
e3943df
docs: correct OAuth node resolver
ewanc26 Oct 4, 2026
d071ec4
fix: expose cJSON headers to OAuth node tool
ewanc26 Oct 4, 2026
0fb466c
ci: run OAuth node branch on pushes
ewanc26 Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI

on:
push:
branches: [main, feat/ci]
branches: [main, feat/ci, feat/oauth-node]
pull_request:
workflow_dispatch:

Expand All @@ -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
Expand Down
23 changes: 21 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -49,13 +49,25 @@ 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)
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)
Expand Down Expand Up @@ -309,7 +321,14 @@ add_library(
# Generic JSON round-trip / schema-subset validation
$<IF:$<BOOL:${WOLFRAM_BUILD_NON_POSIX}>,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
$<$<BOOL:${WOLFRAM_BUILD_OAUTH_NODE}>:src/node/oauth_node.c>)

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()

# The version lives only in the `VERSION` above; derive the public
# WOLFRAM_VERSION_* macros from PROJECT_VERSION at compile time.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions docs/oauth-node.md
Original file line number Diff line number Diff line change
@@ -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/<pair-code>`
- `GET /oauth/callback`
- `POST /xrpc/uk.ewancroft.oauth.begin`
- `GET /xrpc/uk.ewancroft.oauth.poll?code=<pair-code>`
- authenticated XRPC proxying for console sessions

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

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.
2 changes: 2 additions & 0 deletions docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions include/wolfram/agent.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions include/wolfram/oauth_node.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#ifndef WOLFRAM_OAUTH_NODE_H
#define WOLFRAM_OAUTH_NODE_H

#include <stdint.h>
#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
32 changes: 32 additions & 0 deletions src/agent/agent.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading