oauth_manager

Per-user OAuth token management with encrypted storage.

Handles the full OAuth2 authorization-code flow for multiple providers (GitHub, Google, Discord, Microsoft), stores tokens encrypted at rest in Redis, and transparently refreshes expired access tokens.

Redis key pattern:

stargazer:oauth_tokens:{user_id}:{provider} (Fernet-encrypted JSON) stargazer:oauth_link:{link_code} (one-time link code -> user_id)

class oauth_manager.OAuthProvider(name, authorize_url, token_url, client_id='', client_secret='', scopes=<factory>, tokens_expire=True, revoke_url='', extra_auth_params=<factory>)[source]

Bases: object

Configuration for a single OAuth2 provider.

Holds the static endpoints (authorize/token/revoke URLs) and the per-deployment credentials and scopes for one provider, so the rest of the manager can build authorization URLs and exchange/refresh tokens without re-deriving provider-specific quirks. Instances are created in OAuthManager.__init__() by merging a deployment’s providers_config over the static PROVIDER_TEMPLATES, and are read by OAuthManager.get_authorize_url(), OAuthManager.exchange_code(), OAuthManager._refresh_token() and OAuthManager._revoke().

Parameters:
  • name (str) – Provider name (e.g. "github", "google").

  • authorize_url (str) – OAuth2 authorization endpoint.

  • token_url (str) – OAuth2 token endpoint for code exchange and refresh.

  • client_id (str) – OAuth client id for this deployment.

  • client_secret (str) – OAuth client secret for this deployment.

  • scopes (list[str]) – Default scopes requested at authorization time.

  • tokens_expire (bool) – Whether issued access tokens expire (False for providers like GitHub whose tokens are long-lived).

  • revoke_url (str) – Optional revocation endpoint; empty when unsupported.

  • extra_auth_params (dict[str, str]) – Extra query params merged into the authorize URL (e.g. Google’s access_type/prompt).

name: str
authorize_url: str
token_url: str
client_id: str = ''
client_secret: str = ''
scopes: list[str]
tokens_expire: bool = True
revoke_url: str = ''
extra_auth_params: dict[str, str]
class oauth_manager.TokenData(access_token, refresh_token='', expires_at=0.0, scopes=<factory>, token_type='Bearer', provider='')[source]

Bases: object

Decrypted OAuth token bundle.

The in-memory, plaintext representation of a user’s OAuth credentials for one provider. It carries the access/refresh tokens, an absolute expires_at epoch and the granted scopes, and is the unit that OAuthManager serializes (via to_dict()) and Fernet-encrypts before writing to Redis, and reconstructs (via from_dict()) after decrypting. Produced by OAuthManager.exchange_code() and OAuthManager._refresh_token(), and consumed throughout the manager’s load/store/refresh/revoke paths.

Parameters:
  • access_token (str) – The bearer access token used to call provider APIs.

  • refresh_token (str) – The refresh token, if the provider issued one.

  • expires_at (float) – Absolute epoch seconds at which the access token expires; 0 means it never expires.

  • scopes (list[str]) – Scopes actually granted by the provider.

  • token_type (str) – Token type, normally "Bearer".

  • provider (str) – Provider name this bundle belongs to.

access_token: str
refresh_token: str = ''
expires_at: float = 0.0
scopes: list[str]
token_type: str = 'Bearer'
provider: str = ''
property is_expired: bool

Report whether the access token is at or past its refresh threshold.

Treats a non-positive expires_at as a non-expiring token (e.g. GitHub, which issues tokens that never expire) and otherwise compares the current wall-clock time against expires_at minus REFRESH_BUFFER_SECONDS so refresh happens slightly before true expiry.

Consulted by OAuthManager.get_token() to decide whether a proactive refresh is required before handing the token to a tool. No internal callers exist elsewhere in this module.

Returns:

True if the token should be refreshed (within the buffer window of expiry), False if it is still valid or never expires.

Return type:

bool

to_dict()[source]

Serialize this token bundle to a plain JSON-compatible dict.

Produces the exact shape persisted to Redis. Called by OAuthManager._store_token(), which JSON-encodes and Fernet-encrypts the result before writing it under the per-user token key. The inverse is from_dict().

Returns:

Mapping with access_token, refresh_token, expires_at, scopes, token_type and provider keys.

Return type:

dict[str, Any]

classmethod from_dict(d)[source]

Reconstruct a TokenData from its serialized dict form.

Coerces expires_at to float and fills sensible defaults for any missing keys, making it tolerant of partial or legacy payloads. Called by OAuthManager._load_token() after decrypting and JSON-decoding the stored token. Inverse of to_dict().

Parameters:

d (dict[str, Any]) – Decoded token mapping as produced by to_dict() (any subset of its keys).

Returns:

A token bundle populated from d.

Return type:

TokenData

exception oauth_manager.OAuthNotConnected(provider, connect_url)[source]

Bases: Exception

Raised when a tool needs an OAuth token the user hasn’t provided.

Signals that a provider-backed tool cannot run because the user has not yet authorized that provider. The exception carries the provider name and a one-time connect URL so the surrounding tool layer can turn it into a user-facing “click here to connect” prompt rather than a hard error. Raised by require_oauth_token() and surfaced to the chat user by the OAuth tools (tools/microsoft_tools.py, tools/google_oauth_tools.py, tools/github_tools.py, tools/discord_user_tools.py).

Parameters:
  • provider (str)

  • connect_url (str)

Return type:

None

__init__(provider, connect_url)[source]

Build the exception with a user-facing connect prompt.

Stores provider and connect_url as attributes and composes a human-readable message instructing the user to click the link to connect the provider. Raised by require_oauth_token() when a tool needs a token the user has not yet granted; the message is surfaced back to the chat user.

Parameters:
  • provider (str) – Provider name the user must connect (e.g. "github").

  • connect_url (str) – One-time OAuth authorization URL the user should open to grant access.

Return type:

None

class oauth_manager.OAuthManager(encryption_key='', base_url='', providers_config=None)[source]

Bases: object

Core OAuth2 token lifecycle manager.

Owns the full per-user OAuth story for every configured provider: building authorization URLs, exchanging authorization codes, transparently refreshing expired access tokens, revoking on disconnect, and persisting every token Fernet-encrypted in Redis under stargazer:oauth_tokens:{user_id}:{provider}. It also drives the chat-initiated connect flow via one-time link codes (stargazer:oauth_link:{code}) and opaque state tokens (stargazer:oauth_state:{state}). A single instance is created and held as the module singleton by init_oauth_manager() / get_oauth_manager(), and is reached by the web OAuth routes (web/auth_routes.py), the connect_service tool (tools/connect_service.py) and, via require_oauth_token(), by every provider-backed tool.

Parameters:
__init__(encryption_key='', base_url='', providers_config=None)[source]

Initialize the manager with encryption, base URL and provider config.

Builds a Fernet cipher from encryption_key (used to encrypt tokens at rest); if the key is invalid it logs an error and leaves self._fernet as None, in which case tokens are stored in plaintext. Merges per-deployment providers_config (client IDs/secrets/scopes) over the static PROVIDER_TEMPLATES to populate self.providers with one OAuthProvider per supported provider.

Constructed via init_oauth_manager(), which assigns the result to the module-level singleton returned by get_oauth_manager().

Parameters:
  • encryption_key (str) – Fernet key (str or bytes) for at-rest token encryption; empty disables encryption.

  • base_url (str) – Public base URL of the web service, used to build /oauth/{provider}/callback redirect URIs; trailing slash is stripped.

  • providers_config (dict[str, dict[str, Any]] | None) – Optional per-provider overrides keyed by provider name, each supplying client_id, client_secret and/or scopes.

Return type:

None

providers: dict[str, OAuthProvider]
is_provider_configured(provider)[source]

Report whether a provider has usable OAuth credentials.

A provider counts as configured only when it is known and has both a non-empty client_id and client_secret. Called by the web authorize route and the connect_service tool (tools/connect_service.py, web/auth_routes.py) to gate connect attempts, and internally by list_configured_providers().

Parameters:

provider (str) – Provider name to check (e.g. "google").

Returns:

True if the provider exists and has both client credentials set, otherwise False.

Return type:

bool

list_configured_providers()[source]

Return the names of all providers with complete credentials.

Filters self.providers through is_provider_configured(). Called by the web OAuth status/callback routes (web/auth_routes.py) and the connect_service tool to present the user the set of connectable services.

Returns:

Provider names that have both a client ID and secret.

Return type:

list[str]

Mint a one-time link code mapping to a user for the chat connect flow.

Generates a URL-safe random token and stores it in Redis under stargazer:oauth_link:{code} -> user_id with a LINK_CODE_TTL expiry, so a browser hitting the OAuth flow can be tied back to the chat user who requested it. Called by generate_connect_url(); the code is later consumed by resolve_link_code().

Parameters:
  • redis (Any) – Async Redis client supporting set with ex.

  • user_id (str) – User to associate with the generated code.

Returns:

The freshly minted, single-use link code.

Return type:

str

Consume a one-time link code and return its associated user id.

Looks up stargazer:oauth_link:{code} and, on a hit, deletes the key so the code cannot be reused, then returns the user id (decoding bytes if necessary). Called by the OAuth callback route in web/auth_routes.py to recover which chat user initiated a connect when the session has no logged-in user.

Parameters:
  • redis (Any) – Async Redis client supporting get and delete.

  • code (str) – The link code previously issued by create_link_code().

Returns:

The associated user id, or None if the code is unknown or expired.

Return type:

str | None

get_authorize_url(provider, state, scopes=None)[source]

Build the provider’s OAuth2 authorization URL for a redirect.

Assembles the standard authorization-code query string (client id, redirect URI derived from self.base_url, response_type=code, space-joined scopes, and the opaque state) and merges in any provider-specific extra_auth_params such as Google’s access_type=offline/prompt=consent. Called by the web authorize route (web/auth_routes.py) and by generate_connect_url() for the chat-initiated flow.

Parameters:
  • provider (str) – Provider to authorize against.

  • state (str) – Opaque CSRF/round-trip state token echoed back to the callback.

  • scopes (list[str] | None) – Optional scope override; defaults to the provider’s configured scopes.

Returns:

The fully-formed authorization URL to redirect the user to.

Return type:

str

Raises:

ValueError – If provider is not a known provider.

async exchange_code(provider, code)[source]

Exchange an authorization code for an access/refresh token bundle.

Performs the OAuth2 token-endpoint POST (grant_type=authorization_code) for the provider, sending Accept: application/json for GitHub and a form-encoded content type otherwise, then normalizes the response into a TokenData (computing an absolute expires_at from expires_in and splitting a space-delimited scope string into a list). Called by the OAuth callback route in web/auth_routes.py, which subsequently persists the result via store_token().

Parameters:
  • provider (str) – Provider the code was issued by.

  • code (str) – The authorization code returned to the redirect URI.

Returns:

The decrypted token bundle from the provider’s response.

Return type:

TokenData

Raises:

RuntimeError – If the token endpoint returns a non-200 status.

async get_token(user_id, provider, redis)[source]

Return a currently valid access token for a user/provider, refreshing if needed.

The primary read path tools use to obtain credentials. It loads the stored token via _load_token() and, when the token reports TokenData.is_expired and a refresh token is present, transparently renews it via _refresh_token() (which re-persists the result). If a refresh fails it logs the exception, drops the broken credentials via _delete_token(), and returns None so the caller can prompt the user to reconnect. Returns None (rather than raising) whenever redis is unavailable, no token is stored, or the token has no access token. Called by require_oauth_token(), which wraps a None result in OAuthNotConnected.

Parameters:
  • user_id (str) – Owner of the token.

  • provider (str) – Provider whose token to return.

  • redis (Any) – Async Redis client, or None.

Returns:

A valid access token string, or None if the user is not connected or the token could not be refreshed.

Return type:

str | None

async store_token(redis, user_id, token)[source]

Persist a token bundle for a user (public wrapper of storage).

Thin public entry point that delegates to _store_token() (encrypt + write to Redis). Called by the OAuth callback route in web/auth_routes.py after exchange_code() to save freshly obtained credentials.

Parameters:
  • redis (Any) – Async Redis client.

  • user_id (str) – Owner of the token.

  • token (TokenData) – Token bundle to store.

Return type:

None

async delete_token(redis, user_id, provider)[source]

Revoke (best-effort) and delete a user’s stored token.

Loads the token, and if the provider exposes a revoke_url attempts remote revocation via _revoke() (failures are logged and ignored), then always removes the local copy via _delete_token(). Called by the OAuth disconnect route in web/auth_routes.py and by the connect_service disconnect tool (tools/connect_service.py).

Parameters:
  • redis (Any) – Async Redis client.

  • user_id (str) – Owner of the token to disconnect.

  • provider (str) – Provider to disconnect.

Return type:

None

async list_user_connections(user_id, redis)[source]

Return a summary of every provider the user currently has connected.

Iterates over all known providers, loading each token via _load_token(), and includes only those with a non-empty access token. For each it reports the provider name, granted scopes, an expires_at (None for non-expiring tokens) and whether a refresh token is held, but never the secret token values themselves. This does not trigger a refresh. Called by the web account/status route (web/auth_routes.py) and the connect_service tool (tools/connect_service.py) to render the user’s connection list.

Parameters:
  • user_id (str) – User whose connections to enumerate.

  • redis (Any) – Async Redis client, or None.

Returns:

One dict per connected provider with provider, scopes, expires_at and has_refresh_token keys; empty when redis is None or nothing is connected.

Return type:

list[dict[str, Any]]

async has_token(user_id, provider, redis)[source]

Report whether a user currently has a stored token for a provider.

Loads the token via _load_token() and checks it has a non-empty access token; returns False when redis is None. Unlike get_token(), this does not trigger a refresh. Called by the connect_service tool (tools/connect_service.py) to check and report connection status.

Parameters:
  • user_id (str) – User to check.

  • provider (str) – Provider to check.

  • redis (Any) – Async Redis client, or None.

Returns:

True if a token with a non-empty access token exists, otherwise False.

Return type:

bool

async generate_connect_url(user_id, provider, redis, scopes=None)[source]

Produce a ready-to-click OAuth connect URL for a chat user.

Mints a one-time link code via create_link_code(), stores a JSON state payload (link code + provider) under stargazer:oauth_state:{state} in Redis with a LINK_CODE_TTL expiry keyed by a random opaque state token, and returns the provider authorization URL built by get_authorize_url(). Called by require_oauth_token() (raised inside OAuthNotConnected) and by the connect_service tool (tools/connect_service.py) so a user can authorize from chat.

Parameters:
  • user_id (str) – User initiating the connection from chat.

  • provider (str) – Provider to connect.

  • redis (Any) – Async Redis client supporting set with ex.

  • scopes (list[str] | None) – Optional scope override forwarded to get_authorize_url().

Returns:

The provider authorization URL the user should open.

Return type:

str

oauth_manager.get_oauth_manager()[source]

Return the process-wide OAuthManager singleton.

Accessor for the single manager instance created at service startup by init_oauth_manager(); it reads the module-level _instance and raises if initialization has not happened yet, so callers never get a half-configured manager. Called by the web OAuth routes (web/auth_routes.py), the connect_service tool (tools/connect_service.py) and require_oauth_token().

Returns:

The initialized global manager.

Return type:

OAuthManager

Raises:

RuntimeError – If init_oauth_manager() has not been called yet.

oauth_manager.init_oauth_manager(encryption_key='', base_url='', providers_config=None)[source]

Construct the global OAuthManager and store it as the singleton.

Instantiates an OAuthManager with the given encryption key, public base URL, and per-provider credentials, assigns it to the module-level _instance, and returns it. This is the initialization entry point that must run before get_oauth_manager() (typically at service startup); no internal callers exist in this repository.

Parameters:
  • encryption_key (str) – Fernet key for at-rest token encryption (empty disables encryption).

  • base_url (str) – Public base URL used to derive OAuth callback redirect URIs.

  • providers_config (dict[str, dict[str, Any]] | None) – Optional per-provider credential/scope overrides.

Returns:

The newly created and now-global manager instance.

Return type:

OAuthManager

async oauth_manager.require_oauth_token(ctx, provider)[source]

Return a valid access token for the current user or demand a connection.

The single guard every provider-backed tool calls to obtain credentials. It pulls the manager from get_oauth_manager() and asks OAuthManager.get_token() for a (possibly refreshed) token using the user and Redis client carried on the tool ctx. On success it returns the bare access-token string; otherwise it mints a one-time connect URL via OAuthManager.generate_connect_url() and raises OAuthNotConnected, which the tool layer surfaces to the chat user as a clickable connect prompt. Called by _get_token helpers in tools/microsoft_tools.py, tools/google_oauth_tools.py, tools/github_tools.py and tools/discord_user_tools.py.

Parameters:
  • ctx (Any) – Tool context exposing user_id and redis.

  • provider (str) – Provider whose access token is required.

Returns:

A valid access token for provider.

Return type:

str

Raises:

OAuthNotConnected – If the user has not connected the provider (or the token could not be refreshed), carrying a connect URL.