Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **All SDKs:** `OAuth.revoke()` revokes the current token on the server through the existing RFC 7009 endpoint (`POST /oauth2/revoke`): the refresh token when present, otherwise the access token. It only contacts the server; the in-memory and cached tokens are left untouched. On a client created with `from_token` (no client ID) it fails with a `RevokeFailed` error instead of calling the server.
- **All SDKs:** `OAuth.clear()` drops the token locally: it clears the in-memory copy and deletes the cached token file (`~/.longbridge/openapi/tokens/<client_id>`; a missing file is not an error). It does not contact the server, so call `revoke()` first to disconnect the application completely. After `clear()`, `access_token()` fails with `NoTokenAvailable`. The persisted token is deleted first and the in-memory token is cleared only when that succeeds, so a failed delete leaves the handle usable and the error is returned.
- Rust: `OAuth::revoke` / `OAuth::clear` (async), `OAuthError::RevokeFailed`, `OAuthError::TokenFileDelete`. C: `lb_oauth_revoke` / `lb_oauth_clear`. C++: `OAuth::revoke` / `OAuth::clear`. Java: `OAuth.revoke()` / `OAuth.clear()` returning `CompletableFuture<Void>`. Node.js: `oauth.revoke()` / `oauth.clear()` returning promises. Python: `OAuth.revoke()` / `OAuth.clear()` plus `revoke_async()` / `clear_async()`; the `openapi.pyi` stub is updated. The blocking Python calls release the GIL while waiting. In C/C++, a NULL handle (e.g. a moved-from C++ `OAuth`) is reported through the callback as an error instead of crashing.
- **Rust:** `TokenStorage::delete(client_id)` removes a persisted token. It has a default implementation that returns an error, so existing custom storage backends keep compiling but `OAuth::clear` fails loudly (and keeps the in-memory token) instead of reporting success without wiping anything; override it to support `clear`. `FileTokenStorage` deletes the token file.

### Breaking changes

- **Rust:** `OAuthError` is now `#[non_exhaustive]` and gains `RevokeFailed` and `TokenFileDelete`. Exhaustive `match`es on `OAuthError` outside this crate need a wildcard arm; adding variants will no longer be a breaking change.

### Fixed

- **All SDKs:** the OAuth browser authorization flow now uses PKCE (RFC 7636, `S256`): the authorization request carries a `code_challenge` and the token exchange sends the matching `code_verifier`. The server requires PKCE for `public` clients, so previously such clients (e.g. those created through dynamic client registration) could not authorize through the SDK and failed with `OAuth authorization failed: server_error`. Clients that don't require PKCE are unaffected.

## [5.3.0] - 2026-10-23

### Added
Expand Down
26 changes: 26 additions & 0 deletions c/csrc/include/longbridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -15652,6 +15652,32 @@ struct lb_oauth_t *lb_oauth_clone(const struct lb_oauth_t *oauth);
*/
void lb_oauth_free(struct lb_oauth_t *oauth);

/**
* Asynchronously revoke the current token on the server (RFC 7009)
*
* The refresh token is revoked when present, otherwise the access token.
* Only the server is contacted; the cached token on disk is left untouched
* (use `lb_oauth_clear` to remove it).
*
* @param oauth OAuth 2.0 client; if NULL, `callback` receives an error
* @param callback Async completion callback; `error` is set on failure
* @param userdata Opaque pointer forwarded to `callback`
*/
void lb_oauth_revoke(const struct lb_oauth_t *oauth, lb_async_callback_t callback, void *userdata);

/**
* Asynchronously clear the token locally
*
* Drops the in-memory token and deletes the cached token file
* (`~/.longbridge/openapi/tokens/<client_id>`). The server is not contacted
* (use `lb_oauth_revoke` for that).
*
* @param oauth OAuth 2.0 client; if NULL, `callback` receives an error
* @param callback Async completion callback; `error` is set on failure
* @param userdata Opaque pointer forwarded to `callback`
*/
void lb_oauth_clear(const struct lb_oauth_t *oauth, lb_async_callback_t callback, void *userdata);

const struct lb_portfolio_context_t *lb_portfolio_context_new(const struct lb_config_t *config);

void lb_portfolio_context_retain(const struct lb_portfolio_context_t *ctx);
Expand Down
62 changes: 62 additions & 0 deletions c/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,65 @@ pub unsafe extern "C" fn lb_oauth_free(oauth: *mut COAuth) {
drop(Box::from_raw(oauth));
}
}

/// Clone the inner client; `None` if the handle is NULL (e.g. a moved-from C++
/// wrapper).
unsafe fn clone_inner(oauth: *const COAuth) -> Option<OAuth> {
oauth.as_ref().map(|oauth| oauth.inner.clone())
}

fn null_oauth_error() -> longbridge::Error {
longbridge::Error::OAuth("OAuth client is NULL".to_string())
}

/// Asynchronously revoke the current token on the server (RFC 7009)
///
/// The refresh token is revoked when present, otherwise the access token.
/// Only the server is contacted; the cached token on disk is left untouched
/// (use `lb_oauth_clear` to remove it).
///
/// @param oauth OAuth 2.0 client; if NULL, `callback` receives an error
/// @param callback Async completion callback; `error` is set on failure
/// @param userdata Opaque pointer forwarded to `callback`
#[unsafe(no_mangle)]
pub unsafe extern "C" fn lb_oauth_revoke(
oauth: *const COAuth,
callback: CAsyncCallback,
userdata: *mut c_void,
) {
let oauth = clone_inner(oauth);
execute_async::<c_void, _, _>(callback, std::ptr::null(), userdata, async move {
oauth
.ok_or_else(null_oauth_error)?
.revoke()
.await
.map_err(|e| longbridge::Error::OAuth(e.to_string()))?;
Ok(())
});
}

/// Asynchronously clear the token locally
///
/// Drops the in-memory token and deletes the cached token file
/// (`~/.longbridge/openapi/tokens/<client_id>`). The server is not contacted
/// (use `lb_oauth_revoke` for that).
///
/// @param oauth OAuth 2.0 client; if NULL, `callback` receives an error
/// @param callback Async completion callback; `error` is set on failure
/// @param userdata Opaque pointer forwarded to `callback`
#[unsafe(no_mangle)]
pub unsafe extern "C" fn lb_oauth_clear(
oauth: *const COAuth,
callback: CAsyncCallback,
userdata: *mut c_void,
) {
let oauth = clone_inner(oauth);
execute_async::<c_void, _, _>(callback, std::ptr::null(), userdata, async move {
oauth
.ok_or_else(null_oauth_error)?
.clear()
.await
.map_err(|e| longbridge::Error::OAuth(e.to_string()))?;
Ok(())
});
}
18 changes: 18 additions & 0 deletions cpp/include/oauth.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,24 @@ class OAuth
~OAuth();

operator const lb_oauth_t*() const { return oauth_; }

/// Asynchronously revoke the current token on the server (RFC 7009)
///
/// The refresh token is revoked when present, otherwise the access token.
/// Only the server is contacted; the cached token on disk is left untouched
/// (see `clear`).
///
/// @param callback Invoked on completion; check `status()` for errors
void revoke(AsyncCallback<NoContext, void> callback) const;

/// Asynchronously clear the token locally
///
/// Drops the in-memory token and deletes the cached token file
/// (`~/.longbridge/openapi/tokens/<client_id>`). The server is not contacted
/// (see `revoke`).
///
/// @param callback Invoked on completion; check `status()` for errors
void clear(AsyncCallback<NoContext, void> callback) const;
};

/// Builder for constructing an OAuth 2.0 client
Expand Down
24 changes: 24 additions & 0 deletions cpp/src/oauth.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ OAuth::~OAuth()
lb_oauth_free(oauth_);
}

void
OAuth::revoke(AsyncCallback<NoContext, void> callback) const
{
lb_oauth_revoke(
oauth_,
[](const lb_async_result_t* res) {
auto callback_ptr = callback::get_async_callback<NoContext, void>(res->userdata);
(*callback_ptr)(AsyncResult<NoContext, void>(NoContext{}, Status(res->error), nullptr));
},
new AsyncCallback<NoContext, void>(callback));
}

void
OAuth::clear(AsyncCallback<NoContext, void> callback) const
{
lb_oauth_clear(
oauth_,
[](const lb_async_result_t* res) {
auto callback_ptr = callback::get_async_callback<NoContext, void>(res->userdata);
(*callback_ptr)(AsyncResult<NoContext, void>(NoContext{}, Status(res->error), nullptr));
},
new AsyncCallback<NoContext, void>(callback));
}

// ── OAuthBuilder ─────────────────────────────────────────────────────────────

OAuthBuilder::OAuthBuilder(const std::string& client_id, uint16_t callback_port)
Expand Down
36 changes: 36 additions & 0 deletions java/javasrc/src/main/java/com/longbridge/OAuth.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.longbridge;

import java.util.concurrent.CompletableFuture;

/**
* OAuth 2.0 client handle for Longbridge OpenAPI
*
Expand Down Expand Up @@ -46,6 +48,40 @@ long getRaw() {
return r;
}

/**
* Revoke the current token on the server (RFC 7009).
*
* <p>
* The refresh token is revoked when present, otherwise the access token.
* Only the server is contacted; the cached token on disk is left untouched
* (see {@link #clear()}).
*
* @return A Future that completes when the token has been revoked
*/
public CompletableFuture<Void> revoke() {
long r = getRaw();
return AsyncCallback.executeTask((callback) -> {
SdkNative.oauthRevoke(r, callback);
});
}

/**
* Clear the token locally.
*
* <p>
* Drops the in-memory token and deletes the cached token file
* ({@code ~/.longbridge/openapi/tokens/<clientId>}). The server is not
* contacted (see {@link #revoke()}).
*
* @return A Future that completes when the local token has been cleared
*/
public CompletableFuture<Void> clear() {
long r = getRaw();
return AsyncCallback.executeTask((callback) -> {
SdkNative.oauthClear(r, callback);
});
}

@Override
public synchronized void close() {
long h = this.raw;
Expand Down
4 changes: 4 additions & 0 deletions java/javasrc/src/main/java/com/longbridge/SdkNative.java
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ public static native void configRefreshAccessToken(long config, OffsetDateTime e
public static native void oauthBuild(String clientId, int callbackPort,
Consumer<String> openUrlCallback, AsyncCallback callback);

public static native void oauthRevoke(long oauth, AsyncCallback callback);

public static native void oauthClear(long oauth, AsyncCallback callback);

public static native void freeOAuth(long oauth);

public static native long newAssetContext(long config);
Expand Down
40 changes: 40 additions & 0 deletions java/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,43 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_freeOAuth(
) {
drop(Box::from_raw(oauth as *mut OAuth));
}

/// Asynchronously revoke the current token on the server (RFC 7009).
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_longbridge_SdkNative_oauthRevoke(
mut env: JNIEnv,
_class: JClass,
oauth: jlong,
callback: JObject,
) {
jni_result(&mut env, (), |env| {
let oauth = (*(oauth as *const OAuth)).clone();
async_util::execute(env, callback, async move {
oauth
.revoke()
.await
.map_err(|e| crate::error::JniError::Other(e.to_string()))
})?;
Ok(())
})
}

/// Asynchronously clear the token locally (memory and persisted file).
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_longbridge_SdkNative_oauthClear(
mut env: JNIEnv,
_class: JClass,
oauth: jlong,
callback: JObject,
) {
jni_result(&mut env, (), |env| {
let oauth = (*(oauth as *const OAuth)).clone();
async_util::execute(env, callback, async move {
oauth
.clear()
.await
.map_err(|e| crate::error::JniError::Other(e.to_string()))
})?;
Ok(())
})
}
27 changes: 27 additions & 0 deletions nodejs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2551,6 +2551,33 @@ export declare class OAuth {
* `HttpClient.fromOAuth`
*/
static build(clientId: string, onOpenUrl: ((err: Error | null, arg: string) => void), callbackPort?: number | undefined | null): Promise<OAuth>
/**
* Revoke the current token on the server (RFC 7009).
*
* The refresh token is revoked when present, otherwise the access token.
* Only the server is contacted; the cached token on disk is left
* untouched (see `clear`).
*
* @example
* ```javascript
* const oauth = await OAuth.build('your-client-id', (_, url) => console.log('Open:', url));
* await oauth.revoke();
* ```
*/
revoke(): Promise<void>
/**
* Clear the token locally.
*
* Drops the in-memory token and deletes the cached token file
* (`~/.longbridge/openapi/tokens/<clientId>`). The server is not
* contacted (see `revoke`).
*
* @example
* ```javascript
* await oauth.clear();
* ```
*/
clear(): Promise<void>
}

/**
Expand Down
37 changes: 37 additions & 0 deletions nodejs/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,41 @@ impl OAuth {
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(OAuth(oauth))
}

/// Revoke the current token on the server (RFC 7009).
///
/// The refresh token is revoked when present, otherwise the access token.
/// Only the server is contacted; the cached token on disk is left
/// untouched (see `clear`).
///
/// @example
/// ```javascript
/// const oauth = await OAuth.build('your-client-id', (_, url) => console.log('Open:', url));
/// await oauth.revoke();
/// ```
#[napi]
pub async fn revoke(&self) -> Result<()> {
self.0
.revoke()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}

/// Clear the token locally.
///
/// Drops the in-memory token and deletes the cached token file
/// (`~/.longbridge/openapi/tokens/<clientId>`). The server is not
/// contacted (see `revoke`).
///
/// @example
/// ```javascript
/// await oauth.clear();
/// ```
#[napi]
pub async fn clear(&self) -> Result<()> {
self.0
.clear()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}
22 changes: 22 additions & 0 deletions python/pysrc/longbridge/openapi.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,28 @@ class OAuth:
:meth:`Config.from_oauth` or :meth:`HttpClient.from_oauth`.
"""

def revoke(self) -> None:
"""
Revoke the current token on the server (RFC 7009), blocking.

The refresh token is revoked when present, otherwise the access token.
Only the server is contacted; the cached token on disk
(``~/.longbridge/openapi/tokens/<client_id>``) is left untouched.
Call :meth:`clear` afterwards to drop it locally.
"""
async def revoke_async(self) -> None:
"""Async version of :meth:`revoke`."""
def clear(self) -> None:
"""
Clear the token locally, blocking.

Drops the in-memory token and deletes the cached token file
(``~/.longbridge/openapi/tokens/<client_id>``). The server is not
contacted; use :meth:`revoke` for that.
"""
async def clear_async(self) -> None:
"""Async version of :meth:`clear`."""

class OAuthBuilder:
"""
Builder for the OAuth 2.0 authorization flow.
Expand Down
Loading
Loading