Skip to content

Add PawOAuthSession for interactive OAuth sign-in to PAW - #1494

Draft
bdunleavy22 wants to merge 7 commits into
cubewise-code:masterfrom
bdunleavy22:paw-oauth
Draft

bdunleavy22 wants to merge 7 commits into
cubewise-code:masterfrom
bdunleavy22:paw-oauth

Conversation

@bdunleavy22

Copy link
Copy Markdown
Contributor

Depends on #1488 (callable access_token, keep_authorization_header). Until that merges, this diff also
shows #1488's commits; only the last four commits are new here.

Open question: does this belong in TM1py.Utils, or would you rather have it in tm1py-samples? It stays out
of RestService and isn't imported by TM1py/__init__.py, so it's easy to move.

Summary

TM1py.Utils.PawOAuth.PawOAuthSession signs a user in to PAW through the browser once (OAuth authorization code
with PKCE), then supplies and refreshes access tokens for TM1Service.

sequenceDiagram
    participant App
    participant PawOAuthSession
    participant Browser
    participant PAW as PAW /oauth2
    participant TM1py
    App->>PawOAuthSession: login()
    PawOAuthSession->>PawOAuthSession: bind 127.0.0.1 listener
    PawOAuthSession->>Browser: open /authorize (PKCE, state)
    Browser-->>PawOAuthSession: redirect with code
    PawOAuthSession->>PAW: exchange code → access + refresh token
    App->>TM1py: TM1Service(access_token=oauth.access_token, keep_authorization_header=True)
    Note over TM1py: on connect / 401 reconnect
    TM1py->>PawOAuthSession: access_token()
    PawOAuthSession->>PAW: refresh if expired
Loading
TM1py/Utils/PawOAuth.py     # PawOAuthSession, TokenCache, InMemoryTokenCache, KeyringTokenCache, OAuthError
Tests/PawOAuth_test.py      # offline: listener, PKCE, token exchange/refresh, keyring cache
docs/getting-started.md     # "Planning Analytics Workspace with OAuth sign-in"
pyproject.toml              # new optional extra: keyring = ["keyring"]

Standard library plus requests; no new required dependencies. cache="keyring" keeps the refresh token in the
OS credential store so later runs refresh silently; the default is cache="in_memory", and any TokenCache can be
passed instead. The keyring entry is keyed on token endpoint host, client ID and scope (never the secret). It needs
the optional keyring package (pip install TM1py[keyring]), which is imported only when that cache is created.

oauth = PawOAuthSession(auth_url=..., token_url=..., client_id=..., redirect_uri=..., cache="keyring")
oauth.login()  # browser the first time, silent refresh afterwards

Evidence

Offline unit tests (Tests/PawOAuth_test.py, 36 tests; keyring is faked, so CI doesn't need it), including:

browser redirects back instantly            -> captured (listener is bound before the browser opens)
idle speculative browser connection         -> redirect still captured within the 5 s read timeout
redirect port already in use                -> OAuthError naming the port
redirect_uri with "localhost"               -> ValueError (loopback IP literals only, RFC 8252 8.3)
forged state / error=access_denied          -> OAuthError
cached refresh token                        -> login() refreshes silently, no browser
expired access token                        -> access_token() refreshes and stores the rotated refresh token
KeyringTokenCache, new process              -> login() refreshes from the stored token, no browser
3500-char refresh token                     -> stored in chunks, read back intact
keyring not installed                       -> ImportError: pip install TM1py[keyring]

KeyringTokenCache against the real Windows Credential Manager backend: without chunking, values over 1280
characters fail with CredWrite: The stub received bad data. With 1000-character chunks, tokens of 100–4000
characters round-trip and are fully removed afterwards.

Against real instances with my own user (manual smoke test, not committed):

TM1 11.8 on IBM Cloud TM1 12.5.9 PAaaS
Browser sign-in, refresh token issued ✅ ✅
base_url that accepts the token https://<env>.planning-analytics.cloud.ibm.com/api/v1/tm1/<db> https://<region>.planninganalytics.saas.ibm.com/api/<tenant>/v0/tm1/<db>
Connect + whoami ✅ ✅
Force expiry → re_connect() → new token → whoami ✅ ✅
Same with keep_authorization_header=False 401 after session start 401 after session start

Both used scope=v0userContext and a confidential client (secret sent as HTTP Basic). On TM1 11, /tm1/api/<db>
rejects the token (WWW-Authenticate: CAMPassport …): it only accepts CAM, so the docs point to PAW's /api/v1
route instead. With cache="keyring", the first run opened the browser and the second signed in silently.

Merge Danger

Door: two-way for behaviour, but adds public API

Nothing existing imports it, so it can be reverted freely before a release. Once released, renaming or removing
PawOAuthSession would break users, so the name and location are worth settling in review.

Blast Radius: none

New module and a new optional extra only; the module isn't imported by TM1py/__init__.py. Existing connections
and installs are unaffected. Opening a browser and binding a local port happen only when a caller explicitly calls
login().

🤖 Generated with Claude Code

bdunleavy22 and others added 7 commits October 1, 2026 12:03
Add option to keep Authorization header on requests
…oken

- Parse keep_authorization_header with translate_to_boolean, so a config.ini
  value of "False" no longer switches it on.
- Document keep_authorization_header as an option for token-based gateways
  (e.g. PAW's OAuth route), and document access_token.
- Accept a zero-argument callable for access_token. It is resolved on every
  connect and reconnect, so a refreshing OAuth client supplies a fresh token
  after a session timeout instead of the stale one.
- Add offline unit tests for the header being kept/removed, the "False"
  string, and the callable being resolved on each session start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TM1py.Utils.PawOAuth runs the OAuth 2.0 authorization-code flow with PKCE
against Planning Analytics Workspace, then refreshes the access token on
demand. Its access_token method is passed to TM1Service as a callable
access_token, together with keep_authorization_header=True.

It stays outside RestService because the flow opens a browser; the caller
decides when an interactive sign-in is acceptable, and TM1py itself stays
headless. Standard library plus requests only.

- The loopback listener is bound before the browser opens, so an instant
  redirect is not refused.
- Only 127.0.0.1 / [::1] redirect URIs are accepted (RFC 8252 8.3), since
  "localhost" may resolve to the other address family than the listener.
- A busy port raises OAuthError; address reuse is off on Windows, where it
  would let the listener share a port with another process.
- Requests have a read timeout, so a browser's idle speculative connection
  cannot block the single-threaded listener.
- Offline unit tests for the listener and token handling, and a section in
  the getting-started docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both were confirmed against real instances (TM1 11.8 on IBM Cloud, TM1 12.5.9
PAaaS): the OAuth token is accepted on PAW's API route, not on TM1 11's
/tm1/api/<db> endpoint, which only accepts CAM authentication.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps the refresh token in the OS credential store via keyring, so later
processes refresh silently instead of opening the browser. keyring is an
optional extra (pip install TM1py[keyring]) and imported only when the cache
is created, with an ImportError naming the extra if it is missing.

Windows Credential Manager rejects values over 1280 UTF-16 characters
("The stub received bad data"), which refresh tokens can exceed, so the token
is stored in 1000-character chunks plus a count entry written last. Checked
against the real WinVaultKeyring backend with tokens up to 4000 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cache now accepts "in_memory" (the default, also None), "keyring", or a
TokenCache instance. "keyring" builds a KeyringTokenCache keyed on the token
endpoint host, client ID and scope, with characters outside [A-Za-z0-9._-]
replaced and a short hash of the exact values appended, so sign-ins that only
differ in replaced characters stay separate. The client secret is left out of
the key: key names are visible in the credential store, and a rotated secret
should keep the stored token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant