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:
objectConfiguration 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’sproviders_configover the staticPROVIDER_TEMPLATES, and are read byOAuthManager.get_authorize_url(),OAuthManager.exchange_code(),OAuthManager._refresh_token()andOAuthManager._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 (Falsefor 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’saccess_type/prompt).
- class oauth_manager.TokenData(access_token, refresh_token='', expires_at=0.0, scopes=<factory>, token_type='Bearer', provider='')[source]
Bases:
objectDecrypted 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_atepoch and the granted scopes, and is the unit thatOAuthManagerserializes (viato_dict()) and Fernet-encrypts before writing to Redis, and reconstructs (viafrom_dict()) after decrypting. Produced byOAuthManager.exchange_code()andOAuthManager._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;0means 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.
- property is_expired: bool
Report whether the access token is at or past its refresh threshold.
Treats a non-positive
expires_atas a non-expiring token (e.g. GitHub, which issues tokens that never expire) and otherwise compares the current wall-clock time againstexpires_atminusREFRESH_BUFFER_SECONDSso 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:
Trueif the token should be refreshed (within the buffer window of expiry),Falseif it is still valid or never expires.- Return type:
- 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 isfrom_dict().
- classmethod from_dict(d)[source]
Reconstruct a
TokenDatafrom its serialized dict form.Coerces
expires_attofloatand fills sensible defaults for any missing keys, making it tolerant of partial or legacy payloads. Called byOAuthManager._load_token()after decrypting and JSON-decoding the stored token. Inverse ofto_dict().
- exception oauth_manager.OAuthNotConnected(provider, connect_url)[source]
Bases:
ExceptionRaised 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).- __init__(provider, connect_url)[source]
Build the exception with a user-facing connect prompt.
Stores
providerandconnect_urlas attributes and composes a human-readable message instructing the user to click the link to connect the provider. Raised byrequire_oauth_token()when a tool needs a token the user has not yet granted; the message is surfaced back to the chat user.
- class oauth_manager.OAuthManager(encryption_key='', base_url='', providers_config=None)[source]
Bases:
objectCore 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 byinit_oauth_manager()/get_oauth_manager(), and is reached by the web OAuth routes (web/auth_routes.py), theconnect_servicetool (tools/connect_service.py) and, viarequire_oauth_token(), by every provider-backed tool.- __init__(encryption_key='', base_url='', providers_config=None)[source]
Initialize the manager with encryption, base URL and provider config.
Builds a
Fernetcipher fromencryption_key(used to encrypt tokens at rest); if the key is invalid it logs an error and leavesself._fernetasNone, in which case tokens are stored in plaintext. Merges per-deploymentproviders_config(client IDs/secrets/scopes) over the staticPROVIDER_TEMPLATESto populateself.providerswith oneOAuthProviderper supported provider.Constructed via
init_oauth_manager(), which assigns the result to the module-level singleton returned byget_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}/callbackredirect URIs; trailing slash is stripped.providers_config (
dict[str,dict[str,Any]] |None) – Optional per-provider overrides keyed by provider name, each supplyingclient_id,client_secretand/orscopes.
- 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_idandclient_secret. Called by the web authorize route and theconnect_servicetool (tools/connect_service.py,web/auth_routes.py) to gate connect attempts, and internally bylist_configured_providers().
- list_configured_providers()[source]
Return the names of all providers with complete credentials.
Filters
self.providersthroughis_provider_configured(). Called by the web OAuth status/callback routes (web/auth_routes.py) and theconnect_servicetool to present the user the set of connectable services.
- async create_link_code(redis, user_id)[source]
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_idwith aLINK_CODE_TTLexpiry, so a browser hitting the OAuth flow can be tied back to the chat user who requested it. Called bygenerate_connect_url(); the code is later consumed byresolve_link_code().
- async resolve_link_code(redis, code)[source]
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 inweb/auth_routes.pyto recover which chat user initiated a connect when the session has no logged-in user.- Parameters:
redis (
Any) – Async Redis client supportinggetanddelete.code (
str) – The link code previously issued bycreate_link_code().
- Returns:
The associated user id, or
Noneif the code is unknown or expired.- Return type:
- 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 opaquestate) and merges in any provider-specificextra_auth_paramssuch as Google’saccess_type=offline/prompt=consent. Called by the web authorize route (web/auth_routes.py) and bygenerate_connect_url()for the chat-initiated flow.- Parameters:
- Returns:
The fully-formed authorization URL to redirect the user to.
- Return type:
- Raises:
ValueError – If
provideris 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, sendingAccept: application/jsonfor GitHub and a form-encoded content type otherwise, then normalizes the response into aTokenData(computing an absoluteexpires_atfromexpires_inand splitting a space-delimitedscopestring into a list). Called by the OAuth callback route inweb/auth_routes.py, which subsequently persists the result viastore_token().- Parameters:
- Returns:
The decrypted token bundle from the provider’s response.
- Return type:
- 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 reportsTokenData.is_expiredand 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 returnsNoneso the caller can prompt the user to reconnect. ReturnsNone(rather than raising) wheneverredisis unavailable, no token is stored, or the token has no access token. Called byrequire_oauth_token(), which wraps aNoneresult inOAuthNotConnected.
- 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 inweb/auth_routes.pyafterexchange_code()to save freshly obtained credentials.
- 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_urlattempts remote revocation via_revoke()(failures are logged and ignored), then always removes the local copy via_delete_token(). Called by the OAuth disconnect route inweb/auth_routes.pyand by theconnect_servicedisconnect tool (tools/connect_service.py).
- 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, anexpires_at(Nonefor 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 theconnect_servicetool (tools/connect_service.py) to render the user’s connection list.
- 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; returnsFalsewhenredisisNone. Unlikeget_token(), this does not trigger a refresh. Called by theconnect_servicetool (tools/connect_service.py) to check and report connection status.
- 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) understargazer:oauth_state:{state}in Redis with aLINK_CODE_TTLexpiry keyed by a random opaque state token, and returns the provider authorization URL built byget_authorize_url(). Called byrequire_oauth_token()(raised insideOAuthNotConnected) and by theconnect_servicetool (tools/connect_service.py) so a user can authorize from chat.- Parameters:
- Returns:
The provider authorization URL the user should open.
- Return type:
- oauth_manager.get_oauth_manager()[source]
Return the process-wide
OAuthManagersingleton.Accessor for the single manager instance created at service startup by
init_oauth_manager(); it reads the module-level_instanceand 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), theconnect_servicetool (tools/connect_service.py) andrequire_oauth_token().- Returns:
The initialized global manager.
- Return type:
- 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
OAuthManagerand store it as the singleton.Instantiates an
OAuthManagerwith 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 beforeget_oauth_manager()(typically at service startup); no internal callers exist in this repository.- Parameters:
- Returns:
The newly created and now-global manager instance.
- Return type:
- 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 asksOAuthManager.get_token()for a (possibly refreshed) token using the user and Redis client carried on the toolctx. On success it returns the bare access-token string; otherwise it mints a one-time connect URL viaOAuthManager.generate_connect_url()and raisesOAuthNotConnected, which the tool layer surfaces to the chat user as a clickable connect prompt. Called by_get_tokenhelpers intools/microsoft_tools.py,tools/google_oauth_tools.py,tools/github_tools.pyandtools/discord_user_tools.py.- Parameters:
- Returns:
A valid access token for
provider.- Return type:
- Raises:
OAuthNotConnected – If the user has not connected the provider (or the token could not be refreshed), carrying a connect URL.