Skip to content

feat: add JWT api - #39

Open
TheVaultdweller13 wants to merge 3 commits into
mainfrom
feat/jwt-handle
Open

feat: add JWT api#39
TheVaultdweller13 wants to merge 3 commits into
mainfrom
feat/jwt-handle

Conversation

@TheVaultdweller13

@TheVaultdweller13 TheVaultdweller13 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🚪 Why?

The Ruby SDK only accepts static credentials (api_key / token). Any OAuth2 integration has to hand-roll the entire token lifecycle: exchanging the authorization code, decoding the JWT to know when it expires, refreshing proactively, and surviving single-use refresh-token rotation (if you lose the rotated token, the chain breaks).

🔑 What?

  • F::Token — read-only view over a bearer credential that may be a JWT: claims, expires_at, expired?, expiring_soon?(margin:). Decodes without verifying (verification is the server's job — signature/JWKS stays in factorial-auth, out of scope). Opaque tokens degrade gracefully. Stdlib-only.
  • F::OAuthauthorize_url, exchange_code, refresh against /oauth/token, returning immutable F::OAuth::Tokens. Failures raise F::OAuthError (code/body), mirroring F::ApiError.
  • F::OAuth::Session — self-refreshing, thread-safe token holder with a mandatory rotation callback (persisting the new refresh token is not optional). Wired into F::Api.new(oauth: session) through the generated client's access_token_getter hook, evaluated on every request — no generated code touched, no new dependencies.
  • Fail-fast credentialsF::Api.new without any credential now raises ArgumentError (matching TS/Python) instead of sending unauthenticated requests; empty-string env vars count as absent. ⚠️ Only "breaking" for a case that always 401'd.
  • Scripts now dogfood the public API (oauth_token.rb is a thin CLI over F::OAuth), README documents the flow, and 60 handwritten specs run over a real TCP socket — including an end-to-end one where an about-to-expire token triggers a mid-flight refresh and the API request carries the rotated bearer.

@TheVaultdweller13
TheVaultdweller13 marked this pull request as ready for review July 31, 2026 09:57
@jacobobq jacobobq assigned jacobobq and unassigned jacobobq Aug 3, 2026
Comment thread ruby/lib/factorial_api/token.rb Outdated
@jacobobq
jacobobq requested a review from knifecake August 3, 2026 08:28
Comment thread ruby/lib/factorial_api/api.rb Outdated
Comment thread ruby/lib/factorial_api/oauth.rb
Comment thread ruby/lib/factorial_api/presence.rb Outdated

@knifecake knifecake left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the direction is really good! Adopting OAuth tokens has always been a burden to companies that wanna create their own integrations because of the difficulty in doing the OAuth dance so I think this will be useful in increasing adoption of OAuth (and, more importantly, decrease adoption of API Keys which are less safe).

My only concern is our dependency on the access token format. Not all access tokens are JWTs, and even if we want them to be, we shouldn't depend on it. In particular, the exp claim in JWTs is only an upper bound: currently access tokens may be revoked at any time before their exp timestamp.

I know we have fallbacks and also parse the expires_in field of the /token endpoint response but IMO, a more robust approach would be to refresh once after a request fails due to an authentication error (which is mapped to HTTP status 401 in our public API), then retry the request with the updated token.

I'm not familiar with the API surface we're exposing in our SDKs but maybe it's worth building a request wrapper (if we don't already have one) and hide all of this inside it? (vs your proposed &block approach?) Some other benefits I see around this is that its more standard across languages (JS has callbacks and Python has withs but they seem a bit hacky to me), and that we can model other transport restrictions the API may impose (rate limits) or benefit from (connection reuse).


Also, regarding refresh token reuse, I really like that we're supporting both flows, because we're rebuilding authentication for the Public API in Factorial ID which doesn't reuse refresh tokens.

@TheVaultdweller13

Copy link
Copy Markdown
Contributor Author

@knifecake

Agreed, expiry can't see revocation, so I've added the reactive path.

The SDK already had a request wrapper seat: every generated method funnels through ApiClient#call_api, so an override there now catches a 401, refreshes through the session, and retries the request once (a 401 is rejected before the action runs, so the retry can't replay side effects; concurrent 401s trigger a single refresh, so the rotated single-use refresh token isn't burned). Static credentials propagate the 401 as before. The expiry check stays as an optimization, keyed on the endpoint's expires_in, with the JWT exp only as a fallback, with the 401 retry as the correctness net.

One note on the &block: it's not the refresh trigger, it's the persistence hook for the rotated refresh token

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.

3 participants