diff --git a/ruby/Gemfile.lock b/ruby/Gemfile.lock index 36b17ee..8b1f995 100644 --- a/ruby/Gemfile.lock +++ b/ruby/Gemfile.lock @@ -5,11 +5,18 @@ PATH faraday (>= 1.0.1, < 3.0) faraday-multipart (~> 1.0) marcel (~> 1.0) + oauth2 (~> 2.0) GEM remote: https://rubygems.org/ specs: + anonymous_loader (0.1.3) + version_gem (~> 1.1, >= 1.1.14) ast (2.4.3) + auth-sanitizer (0.2.3) + version_gem (~> 1.1, >= 1.1.14) + base64 (0.3.0) + bigdecimal (4.1.2) byebug (13.0.0) reline (>= 0.6.0) coderay (1.1.3) @@ -22,16 +29,32 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.4) net-http (~> 0.5) + hashie (5.1.0) + logger io-console (0.8.2) json (2.21.1) + jwt (3.2.0) + base64 language_server-protocol (3.17.0.6) lint_roller (1.1.0) logger (1.7.0) marcel (1.2.1) method_source (1.1.0) + multi_xml (0.9.1) + bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) net-http (0.9.1) uri (>= 0.11.1) + oauth2 (2.0.25) + anonymous_loader (~> 0.1, >= 0.1.3) + auth-sanitizer (~> 0.2, >= 0.2.3) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.7) + version_gem (~> 1.1, >= 1.1.14) parallel (1.28.0) parser (3.3.12.0) ast (~> 2.4.1) @@ -45,6 +68,7 @@ GEM byebug (~> 13.0) pry (>= 0.13, < 0.17) racc (1.8.1) + rack (3.2.6) rainbow (3.1.1) rake (13.0.6) regexp_parser (2.12.0) @@ -78,10 +102,14 @@ GEM parser (>= 3.3.7.2) prism (~> 1.7) ruby-progressbar (1.13.0) + snaky_hash (2.0.7) + hashie (>= 0.1.0, < 6) + version_gem (~> 1.1, >= 1.1.14) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) uri (1.1.1) + version_gem (1.1.15) PLATFORMS arm64-darwin-25 diff --git a/ruby/README.md b/ruby/README.md index 835eabb..f4d8108 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -39,8 +39,99 @@ api = F::Api.new(api_key: "YOUR_KEY") api = F::Api.new(token: "YOUR_BEARER_TOKEN") ``` -When an argument is omitted, the client falls back to environment variables: -`FACTORIAL_API_KEY` for the API key, `FACTORIAL_TOKEN` for the token. +When **no credential is passed at all**, the client falls back to the +`FACTORIAL_API_KEY` / `FACTORIAL_TOKEN` environment variables. Passing any +credential explicitly disables the env fallback entirely, so a leftover +exported variable can never ride along with (or veto) the credential you +actually chose. + +### Inspecting a token + +Factorial credentials are opaque strings that happen to be JWTs. `F::Api::Token` +decodes one — **without verifying its signature**; verification is the +server's job — so you can read its claims and plan refreshes: + +```ruby +token = F::Api::Token.new(ENV["FACTORIAL_TOKEN"]) + +token.claims # => {"exp" => 1767225600, "cid" => "42", ...} +token[:cid] # => "42" +token.expires_at # => 2026-01-01 00:00:00 UTC +token.expired? # => false +token.expiring_soon?(margin: 120) # => true within 2 minutes of expiry +``` + +A credential that isn't a decodable JWT is handled gracefully: `claims` is +empty, `expires_at` is `nil`, and `expired?` never reports true — the API +remains the authority on whether it works. + +### OAuth (managed token lifecycle) + +For OAuth2 integrations, `F::Api::OAuth` covers the whole lifecycle: authorize +URL, code exchange, decoding, proactive and reactive refresh, and rotation: + +```ruby +oauth = F::Api::OAuth.new(client_id: "...", client_secret: "...") +# Falls back to FACTORIAL_OAUTH_CLIENT_ID / FACTORIAL_OAUTH_CLIENT_SECRET. + +# 1. Send the user to authorize (browser step, by design): +oauth.authorize_url(redirect_uri: "https://myapp.com/callback") + +# 2. Exchange the code your callback receives (single-use, ~10 min): +tokens = oauth.exchange_code(params[:code], redirect_uri: "https://myapp.com/callback") + +# 3. Wrap the tokens in a self-refreshing session: +session = oauth.session(tokens) do |rotated| + # Refresh tokens are SINGLE-USE: each refresh invalidates the previous + # one. Persist the new one here, or the chain breaks. + save_refresh_token!(rotated.refresh_token) +end + +api = F::Api.new(oauth: session) +api.employees_employee.employees_employees_get(true, false) # required params are positional +``` + +The session checks the access token before every request and refreshes it +when it is within `margin:` seconds of expiry (default 60, configurable via +`oauth.session(tokens, margin: 120)`), judged by the token endpoint's +`expires_in` — so it works even if the access token is not a JWT. Expiry is +only an upper bound (a token can be revoked at any time), so if the API +still rejects the bearer with a 401, the client refreshes reactively and +retries that request once. Token endpoint failures raise `F::Api::OAuthError`, +which carries the HTTP `code` and parsed `body`. + +### Bring your own token source + +`oauth:` is duck-typed: any object that responds to `access_token` and +returns the bearer string works — the built-in session is just the +batteries-included implementation. This is the composition seam for other +token sources (your own cache or vault, or another Factorial gem's token +client) without coupling them to this gem: + +```ruby +class MyTokenSource + def access_token = fetch_current_token_from_somewhere +end + +api = F::Api.new(oauth: MyTokenSource.new) +``` + +A source that also responds to `refresh_after_reject!(rejected_bearer)` — +returning whether it now holds a different bearer — opts into the built-in +401 refresh-and-retry. + +For the common "forward the caller's token" case there is a shortcut: +`access_token:` takes any callable, so one shared client can act on behalf +of whoever is making the current request: + +```ruby +api = F::Api.new(access_token: -> { Current.factorial_token }) +``` + +Both forms are consulted on every request — sometimes more than once per +request — so keep them cheap, idempotent, and thread-safe. `token:`, +`oauth:` and `access_token:` are mutually exclusive: each is a different +way of supplying the same `Authorization: Bearer` header. ### Custom base URL diff --git a/ruby/factorial_api.gemspec b/ruby/factorial_api.gemspec index a071509..7d718d2 100644 --- a/ruby/factorial_api.gemspec +++ b/ruby/factorial_api.gemspec @@ -35,6 +35,7 @@ Gem::Specification.new do |s| s.add_dependency 'faraday', '>= 1.0.1', '< 3.0' s.add_dependency 'faraday-multipart', '~> 1.0' s.add_dependency 'marcel', '~> 1.0' + s.add_dependency 'oauth2', '~> 2.0' s.files = Dir.glob('lib/**/*.rb') + %w[README.md] s.executables = [] diff --git a/ruby/lib/factorial_api/api.rb b/ruby/lib/factorial_api/api.rb index 840da89..6426958 100644 --- a/ruby/lib/factorial_api/api.rb +++ b/ruby/lib/factorial_api/api.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true require 'uri' +require 'factorial_api/oauth' require 'factorial_api/pagination' +require 'factorial_api/token' require 'factorial_api/webhooks' module F @@ -42,15 +44,48 @@ def auth_settings end end + # Every generated method funnels through ApiClient#call_api, which makes + # this override the SDK's request wrapper: the one seat for cross-cutting + # transport behavior. Today that is reauthentication — expiry-based + # refresh cannot see revocation, so a 401 (the API's authoritative "this + # bearer is dead") triggers one reactive refresh and one retry when the + # credential source can mint a replacement. Safe even for writes: a 401 + # is rejected at authentication, before the action runs. Static + # credentials fail exactly as before. + class RefreshingClient < ApiClient + def initialize(config, session:) + super(config) + @session = session + end + + def call_api(http_method, path, opts = {}) + sent = @session&.access_token + super + rescue ApiError => e + # A rescue clause does not cover its own body: a second 401 (or a + # failed refresh) propagates instead of looping. + raise unless e.code == 401 && @session&.refresh_after_reject!(sent) + + super + end + end + attr_reader :client - def initialize(api_key: ENV.fetch('FACTORIAL_API_KEY', nil), token: ENV.fetch('FACTORIAL_TOKEN', nil), - base_url: ENV.fetch('FACTORIAL_BASE_URL', nil)) - config = Config.new - config.api_key['x-api-key'] = api_key - config.access_token = token if token - apply_base_url(config, base_url) if base_url - @client = ApiClient.new(config) + def initialize(api_key: nil, token: nil, base_url: ENV.fetch('FACTORIAL_BASE_URL', nil), + oauth: nil, access_token: nil) + api_key = presence(api_key) + token = presence(token) + # Env credentials are a zero-config convenience, not a supplement: + # passing any credential explicitly disables them, so an exported + # FACTORIAL_TOKEN can neither veto nor ride along with an + # oauth:/access_token: the caller actually chose. + if [api_key, token, oauth, access_token].compact.empty? + api_key = presence(ENV.fetch('FACTORIAL_API_KEY', nil)) + token = presence(ENV.fetch('FACTORIAL_TOKEN', nil)) + end + validate_credentials!(api_key, token, oauth, access_token) + @client = build_client(build_config(api_key, token, oauth, access_token, presence(base_url)), oauth) @apis = {} end @@ -62,6 +97,70 @@ def initialize(api_key: ENV.fetch('FACTORIAL_API_KEY', nil), token: ENV.fetch('F private + def presence(value) + value unless value.nil? || value.to_s.strip.empty? + end + + def validate_credentials!(api_key, token, oauth, access_token) + validate_bearer_sources!(oauth, access_token) + bearers = { token: token, oauth: oauth, access_token: access_token }.compact.keys + if bearers.size > 1 + raise ArgumentError, "#{bearers.join(' and ')} are mutually exclusive — each supplies the bearer" + end + return if api_key || bearers.any? + + raise ArgumentError, + 'provide api_key, token, oauth, or access_token (or set FACTORIAL_API_KEY / FACTORIAL_TOKEN)' + end + + # Misuse of the duck-typed sources fails here, at the constructor, + # instead of as a NoMethodError buried mid-request. + def validate_bearer_sources!(oauth, access_token) + if oauth && !oauth.respond_to?(:access_token) + raise ArgumentError, + 'oauth must respond to #access_token — for a static string use token:, for a callable use access_token:' + end + return unless access_token && !access_token.respond_to?(:call) + + raise ArgumentError, 'access_token must be callable — for a static string use token:' + end + + # Only a source that can mint a replacement bearer opts into the 401 + # retry; the contract is duck-typed like `oauth:` itself. + def build_client(config, oauth) + session = oauth if oauth.respond_to?(:refresh_after_reject!) + RefreshingClient.new(config, session: session) + end + + def build_config(api_key, token, oauth, access_token, base_url) + config = Config.new + config.api_key['x-api-key'] = api_key + config.access_token = token if token + bearer_source = oauth || access_token + config.access_token_getter = bearer_getter(bearer_source) if bearer_source + apply_base_url(config, base_url) if base_url + config + end + + # Consulted by the generated client on EVERY request (via + # Configuration#access_token_with_refresh) — what lets an oauth session + # refresh mid-flight and an access_token callable forward the caller's + # token. May fire more than once per request (auth_settings is rebuilt + # per auth scheme), so sources must stay cheap and idempotent. A source + # that yields no token raises rather than letting the request leave + # unauthenticated (the empty Bearer header would be dropped silently). + def bearer_getter(source) + lambda do + value = (source.respond_to?(:call) ? source.call : source.access_token).to_s + if value.strip.empty? + raise 'F::Api: the oauth/access_token source returned no token — ' \ + 'refusing to send an unauthenticated request' + end + + value + end + end + def apply_base_url(config, base_url) uri = parse_base_url(base_url) diff --git a/ruby/lib/factorial_api/oauth.rb b/ruby/lib/factorial_api/oauth.rb new file mode 100644 index 0000000..5cce81e --- /dev/null +++ b/ruby/lib/factorial_api/oauth.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +require 'monitor' +require 'oauth2' +require_relative 'token' + +module F + module Api + # Raised when the OAuth token endpoint answers with a non-2xx status, a + # malformed body, or a body without a token. Carries the HTTP `code` and + # the parsed response `body`, mirroring how F::Api::ApiError exposes API + # call failures. + class OAuthError < StandardError + # @return [Integer, nil] HTTP status returned by the token endpoint; + # nil when the response was unparseable before a status was known + attr_reader :code + + # @return [Hash, String, nil] parsed JSON body, or the raw body when it + # wasn't JSON + attr_reader :body + + def initialize(code:, body:) + @code = code + @body = body + super("OAuth token endpoint returned #{code ? "HTTP #{code}" : 'an unparseable response'}#{detail}") + end + + # @return [String, nil] the OAuth error identifier ("invalid_grant", ...) + def error + body['error'] if body.is_a?(Hash) + end + + private + + def detail + return '' unless error + + description = body['error_description'] + description ? " (#{error}): #{description}" : " (#{error})" + end + end + + # OAuth2 authorization_code lifecycle against a Factorial instance: + # building the authorize URL, exchanging the code, refreshing, and — via + # #session — keeping an F::Api credential fresh automatically. + # + # oauth = F::Api::OAuth.new(client_id: "...", client_secret: "...") + # oauth.authorize_url(redirect_uri: "https://myapp.com/cb") # browser step + # tokens = oauth.exchange_code(code, redirect_uri: "https://myapp.com/cb") + # session = oauth.session(tokens) { |t| save(t.refresh_token) } + # api = F::Api.new(oauth: session) + # + # Credentials fall back to the FACTORIAL_OAUTH_CLIENT_ID / + # FACTORIAL_OAUTH_CLIENT_SECRET environment variables, and the base URL + # to FACTORIAL_BASE_URL (then production), matching F::Api's conventions. + class OAuth + DEFAULT_BASE_URL = 'https://api.factorialhr.com' + + attr_reader :client_id, :base_url + + def initialize(client_id: ENV.fetch('FACTORIAL_OAUTH_CLIENT_ID', nil), + client_secret: ENV.fetch('FACTORIAL_OAUTH_CLIENT_SECRET', nil), + base_url: ENV.fetch('FACTORIAL_BASE_URL', nil)) + @client_id = presence(client_id) + @client_secret = presence(client_secret) + @base_url = (presence(base_url) || DEFAULT_BASE_URL).chomp('/') + + unless @client_id && @client_secret + raise ArgumentError, + 'provide client_id and client_secret (or set FACTORIAL_OAUTH_CLIENT_ID / ' \ + 'FACTORIAL_OAUTH_CLIENT_SECRET)' + end + + @oauth2_client = build_oauth2_client + end + + # URL where the user grants access in the browser (by design, this step + # cannot be automated). The redirect back carries the single-use + # authorization code (?code=...), valid for ~10 minutes. + def authorize_url(redirect_uri:) + oauth2_client.auth_code.authorize_url(redirect_uri: redirect_uri) + end + + # Exchanges an authorization code for tokens. The redirect_uri must be + # the same one used in the authorize step. + def exchange_code(code, redirect_uri:) + request_tokens { oauth2_client.auth_code.get_token(code, redirect_uri: redirect_uri) } + end + + # Trades a refresh token for a fresh token set. Refresh tokens are + # usually single-use: the returned set normally carries a replacement — + # persist it or the chain breaks. + def refresh(refresh_token) + request_tokens { oauth2_client.get_token(grant_type: 'refresh_token', refresh_token: refresh_token) } + end + + # Wraps a token set in a self-refreshing Session for F::Api's `oauth:`. + # The block is REQUIRED and receives the current Tokens after every + # refresh. + def session(tokens, margin: Token::DEFAULT_MARGIN, &) + Session.new(self, tokens, margin: margin, &) + end + + private + + attr_reader :oauth2_client + + def presence(value) + value unless value.nil? || value.to_s.strip.empty? + end + + # Protocol mechanics live in the oauth2 gem; this wrapper only maps its + # results back into the SDK's value objects and error type. + def request_tokens + Tokens.new(yield.response.parsed) + rescue OAuth2::Error => e + raise OAuthError.new(**error_details(e)) + rescue JSON::ParserError => e + # Raised mid-parse when a body claims to be JSON but isn't — before + # the gem has built an error object that would carry the status. + raise OAuthError.new(code: nil, body: e.message) + end + + # The gem attaches a Response to protocol failures, but raises with + # only the parsed Hash when a 2xx body lacks the access_token key. + def error_details(error) + response = error.response + if response.respond_to?(:status) + { code: response.status, body: parsed_body(response) } + else + { code: 200, body: response } + end + end + + def build_oauth2_client + # Absolute authorize/token URLs: leading-slash paths would drop any + # path prefix present in base_url. + OAuth2::Client.new( + @client_id, @client_secret, + site: base_url, + authorize_url: "#{base_url}/oauth/authorize", + token_url: "#{base_url}/oauth/token", + # Doorkeeper expects the client credentials in the form body (the + # gem's default is HTTP Basic auth). + auth_scheme: :request_body + ) + end + + def parsed_body(response) + response.parsed || response.body + rescue StandardError + response.body + end + + # One response from the token endpoint, as an immutable value. The + # access token comes pre-wrapped in F::Api::Token, so expiry and claims + # are a method call away. + class Tokens + # @return [F::Api::Token] the access token (decoded, not verified) + attr_reader :access_token + + # @return [Hash] the raw token endpoint response, frozen + attr_reader :raw + + # @return [Time, nil] when this set expires — the moment it was + # received plus the endpoint's `expires_in`; nil when the endpoint + # sent none + attr_reader :expires_at + + def initialize(payload) + @raw = payload.dup.freeze + @access_token = Token.new(raw['access_token']) + @expires_at = (Time.now + raw['expires_in'] if raw['expires_in'].is_a?(Numeric)) + freeze + end + + # Whether the set is within `margin` seconds of the earliest KNOWN + # expiry. Two clocks can speak: the endpoint's `expires_in` (anchored + # to the local clock at receipt — immune to server clock skew and to + # non-JWT tokens) and the JWT `exp` claim. Whichever says "sooner" + # wins: refreshing early is harmless, serving a dead token is not. + # A set with neither never reports true — the API stays the + # authority. + # + # The expires_in anchor is set at construction: a Tokens rebuilt + # later from a persisted #to_h restarts it. Persist the + # refresh_token, not the whole set. + def expiring_soon?(margin: Token::DEFAULT_MARGIN, now: Time.now) + earliest = [expires_at, access_token.expires_at].compact.min + !earliest.nil? && (now + margin) >= earliest + end + + # Same set carrying a different refresh token; used when a refresh + # response omits the field (RFC 6749 allows it) and the previous one + # must carry over. + def with_refresh_token(refresh_token) + Tokens.new(raw.merge('refresh_token' => refresh_token)) + end + + # @return [String, nil] the refresh token to persist + def refresh_token = raw['refresh_token'] + def expires_in = raw['expires_in'] + def scope = raw['scope'] + def token_type = raw['token_type'] + def to_h = raw + end + + # The one stateful piece: holds the current Tokens and hands out an + # access token that is refreshed proactively when the set is within + # `margin` seconds of expiry (see Tokens#expiring_soon?). Thread-safe: + # a reentrant Monitor guards the swap, and a refresh holds it for the + # whole token-endpoint round trip — deliberate: it happens once per + # expiry, and letting a second thread refresh concurrently would burn + # the (usually single-use) refresh token. + # + # Built through F::Api::OAuth#session; F::Api's `oauth:` calls + # #access_token before every request, so refreshes happen mid-flight, + # invisibly. Expiry is an upper bound, not a guarantee — a token may be + # revoked at any time — so when the API rejects one with 401 the client + # refreshes reactively too (#refresh_after_reject!) and retries once. + # A failed refresh raises F::Api::OAuthError from that request's call + # site. + class Session + ROTATION_BLOCK_ERROR = 'F::Api::OAuth session requires a rotation block: each refresh ' \ + 'normally invalidates the previous refresh token, so persist ' \ + 'the new one — oauth.session(tokens) { |t| save(t.refresh_token) }' + + # @return [Integer] seconds before expiry at which refresh kicks in + attr_reader :margin + + def initialize(oauth, tokens, margin: Token::DEFAULT_MARGIN, &on_rotation) + raise ArgumentError, ROTATION_BLOCK_ERROR unless on_rotation + + @oauth = oauth + @tokens = tokens + @margin = margin + @on_rotation = on_rotation + @lock = Monitor.new + end + + # @return [Tokens] the current token set + def tokens + @lock.synchronize { @tokens } + end + + # The bearer token to put on the wire, refreshed first when needed. + # Sets without a readable expiry and sets without a refresh token are + # returned as-is: the API stays the authority on rejection. + def access_token + @lock.synchronize do + refresh! if refreshable? && @tokens.expiring_soon?(margin: margin) + @tokens.access_token.raw + end + end + + # Reactive counterpart to #access_token, called by the transport after + # the API rejected `rejected` with 401 — expiry is only an upper + # bound, and revocation is visible to the API alone. Returns whether a + # retry is worth it: whether the session now holds a different bearer. + # Single-flight under rotation: when concurrent requests fail on the + # same token, only the first one refreshes — the rest find the token + # already swapped and skip the endpoint, where a second refresh would + # burn the freshly rotated (single-use) refresh token. + def refresh_after_reject!(rejected) + @lock.synchronize do + refresh! if refreshable? && @tokens.access_token.raw == rejected + @tokens.access_token.raw != rejected + end + end + + private + + def refreshable? + !@tokens.refresh_token.nil? + end + + def refresh! + replacement = @oauth.refresh(@tokens.refresh_token) + # RFC 6749 §6: a refresh response MAY omit refresh_token, meaning + # "keep using the previous one" — dropping it here would silently + # end refreshability and hand the rotation block a nil. + replacement = replacement.with_refresh_token(@tokens.refresh_token) if replacement.refresh_token.nil? + @tokens = replacement + @on_rotation.call(@tokens) + end + end + end + end +end diff --git a/ruby/lib/factorial_api/token.rb b/ruby/lib/factorial_api/token.rb new file mode 100644 index 0000000..e480a76 --- /dev/null +++ b/ruby/lib/factorial_api/token.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'json' + +module F + module Api + # Read-only view over a bearer credential that may be a JWT: exposes its + # claims and expiry with no dependencies beyond the standard library. + # + # token = F::Api::Token.new(ENV["FACTORIAL_TOKEN"]) + # token.claims # => {"exp" => 1767225600, ...} + # token.expires_at # => 2026-01-01 00:00:00 UTC + # token.expiring_soon?(margin: 120) # => false + # + # **Decoding is NOT verifying.** The payload is base64url-decoded without + # checking the signature — enough for a client to introspect its own + # credential (when to refresh, which company it belongs to), never enough + # to trust a token somebody else presents. Verifying signatures is the + # server's job. + # + # Non-JWT input is fine: Factorial credentials are opaque strings that + # just happen to be JWTs today, so anything that doesn't parse yields + # empty claims and no expiry instead of raising. + class Token + # Seconds before `exp` at which #expiring_soon? starts reporting true by + # default: refreshing this early absorbs clock skew and in-flight time. + DEFAULT_MARGIN = 60 + + # @return [String] the credential exactly as given (what goes on the wire) + attr_reader :raw + + # @return [Hash] decoded JWT payload, string-keyed and frozen; empty when + # the credential is not a decodable JWT + attr_reader :claims + + def initialize(raw) + @raw = raw.to_s + @claims = decode_claims.freeze + freeze + end + + # Whether the credential parsed as a JWT (three dot-separated segments + # whose payload is a JSON object). + def jwt? + !claims.empty? + end + + # @return [Time, nil] the `exp` claim as a UTC Time, nil when absent + def expires_at + exp = claims['exp'] + Time.at(exp).utc if exp.is_a?(Numeric) + end + + # True once `now` reaches the `exp` claim. A credential without a + # readable expiry never reports itself expired: the API is the authority. + def expired?(now: Time.now) + exp = expires_at + !exp.nil? && now >= exp + end + + # Like #expired?, but `margin` seconds early — the natural trigger for a + # proactive refresh. + def expiring_soon?(margin: DEFAULT_MARGIN, now: Time.now) + exp = expires_at + !exp.nil? && (now + margin) >= exp + end + + # Claim shorthand, indifferent to symbol/string keys: + # token[:cid] # == token.claims["cid"] + def [](name) + claims[name.to_s] + end + + def to_s + raw + end + + # The raw value is a live credential — show the claims, never the token. + def inspect + "#" + end + + private + + def decode_claims + segments = raw.split('.') + return {} unless segments.size == 3 + + payload = JSON.parse(base64url_decode(segments[1])) + payload.is_a?(Hash) ? payload : {} + rescue StandardError + {} + end + + # base64url (RFC 7515: `-_` alphabet, padding stripped) via unpack — + # the base64 gem is no longer a default gem as of Ruby 3.4. + def base64url_decode(segment) + padded = segment.tr('-_', '+/') + padded += '=' * ((4 - (padded.length % 4)) % 4) + padded.unpack1('m') + end + end + end +end diff --git a/ruby/scripts/oauth_token.rb b/ruby/scripts/oauth_token.rb index bed6cec..9af2fb8 100755 --- a/ruby/scripts/oauth_token.rb +++ b/ruby/scripts/oauth_token.rb @@ -2,9 +2,10 @@ # frozen_string_literal: true # Obtain (and refresh) an OAuth access token — a JWT — from a Factorial -# instance, replicating the authorization_code flow step by step. Dev helper -# for testing token acquisition against local/demo instances; feed the token -# it prints to FACTORIAL_TOKEN / scripts/test_auth.rb. +# instance, walking the authorization_code flow step by step over F::Api::OAuth +# (the SDK's own public API — this script is its manual dogfooding). Dev +# helper for testing token acquisition against local/demo instances; feed +# the token it prints to FACTORIAL_TOKEN / scripts/test_auth.rb. # # The authorize step needs a browser (user consent, by design): the script # prints the URL, you approve there, and paste back the code (or the whole @@ -20,112 +21,73 @@ # WARNING: tokens and secrets are printed to stdout for convenience — do not # paste the output anywhere public. -require 'json' -require 'net/http' -require 'uri' +require_relative '../lib/factorial_api/oauth' + +begin + OAUTH = F::Api::OAuth.new +rescue ArgumentError => e + abort("ERROR: #{e.message} (from your OAuth application in Factorial)") +end -BASE_URL = ENV['FACTORIAL_BASE_URL'].to_s.empty? ? 'https://api.factorialhr.com' : ENV.fetch('FACTORIAL_BASE_URL', nil) -CLIENT_ID = ENV.fetch('FACTORIAL_OAUTH_CLIENT_ID', nil) -CLIENT_SECRET = ENV.fetch('FACTORIAL_OAUTH_CLIENT_SECRET', nil) REDIRECT_URI = if ENV['FACTORIAL_OAUTH_REDIRECT_URI'].to_s.empty? - BASE_URL + OAUTH.base_url else - ENV.fetch('FACTORIAL_OAUTH_REDIRECT_URI', - nil) + ENV.fetch('FACTORIAL_OAUTH_REDIRECT_URI', nil) end -if CLIENT_ID.to_s.empty? || CLIENT_SECRET.to_s.empty? - abort('ERROR: set FACTORIAL_OAUTH_CLIENT_ID and FACTORIAL_OAUTH_CLIENT_SECRET ' \ - '(from your OAuth application in Factorial)') -end - -def post_token(**params) - uri = URI("#{BASE_URL}/oauth/token") - response = Net::HTTP.post_form(uri, params.transform_keys(&:to_s)) - body = begin - JSON.parse(response.body) - rescue StandardError - { 'raw' => response.body[0, 300] } - end - - abort("ERROR: token endpoint returned HTTP #{response.code}: #{body.inspect}") unless response.is_a?(Net::HTTPSuccess) - - body -end - -def decode_claims(jwt) - segments = jwt.to_s.split('.') - return nil unless segments.size == 3 - - # base64url decode without the base64 gem (no longer a default gem in 3.4) - payload = segments[1].tr('-_', '+/') - payload += '=' * ((4 - (payload.length % 4)) % 4) - JSON.parse(payload.unpack1('m')) -rescue StandardError - nil -end - -def show(token_response) - access_token = token_response['access_token'] - +def show(tokens) puts "\n== Token response ==" - puts " token_type: #{token_response['token_type']}" - puts " expires_in: #{token_response['expires_in']}s" - puts " scope: #{token_response['scope']}" - puts " access_token: #{access_token}" - puts " refresh_token: #{token_response['refresh_token']}" - - if (claims = decode_claims(access_token)) + puts " token_type: #{tokens.token_type}" + puts " expires_in: #{tokens.expires_in}s" + puts " scope: #{tokens.scope}" + puts " access_token: #{tokens.access_token}" + puts " refresh_token: #{tokens.refresh_token}" + + token = tokens.access_token + if token.jwt? puts "\n== JWT claims (decoded, unverified) ==" - claims.each { |k, v| puts " #{k.ljust(12)} #{v.to_s[0, 80]}" } - if claims['exp'] && claims['iat'] - puts " (lifetime: #{claims['exp'] - claims['iat']}s, " \ - "expires at #{Time.at(claims['exp']).utc})" + token.claims.each { |k, v| puts " #{k.ljust(12)} #{v.to_s[0, 80]}" } + if token[:exp] && token[:iat] + puts " (lifetime: #{token[:exp] - token[:iat]}s, " \ + "expires at #{token.expires_at})" end else puts "\n(access_token is not a JWT — opaque token)" end puts "\n== Next steps ==" - puts " export FACTORIAL_TOKEN=#{access_token}" - puts " export FACTORIAL_BASE_URL=#{BASE_URL}" + puts " export FACTORIAL_TOKEN=#{token.raw}" + puts " export FACTORIAL_BASE_URL=#{OAUTH.base_url}" puts ' bundle exec ruby scripts/test_auth.rb # verify it against the API' puts "\n To renew when it expires (rotates the refresh token — save the new one!):" - puts " bundle exec ruby scripts/oauth_token.rb --refresh #{token_response['refresh_token']}" + puts " bundle exec ruby scripts/oauth_token.rb --refresh #{tokens.refresh_token}" end -if ARGV[0] == '--refresh' - refresh_token = ARGV[1] or abort('Usage: oauth_token.rb --refresh REFRESH_TOKEN') - - puts "Refreshing access token against #{BASE_URL} ..." - show(post_token( - grant_type: 'refresh_token', - refresh_token: refresh_token, - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET - )) -else - authorize_url = "#{BASE_URL}/oauth/authorize" \ - "?client_id=#{CLIENT_ID}" \ - "&redirect_uri=#{URI.encode_uri_component(REDIRECT_URI)}" \ - '&response_type=code' +begin + if ARGV[0] == '--refresh' + refresh_token = ARGV[1] or abort('Usage: oauth_token.rb --refresh REFRESH_TOKEN') - puts '1. Open this URL in a browser with an active Factorial session:' - puts "\n #{authorize_url}\n\n" - puts '2. Authorize the app. You will be redirected to:' - puts " #{REDIRECT_URI}/?code=XXXX" - print "\n3. Paste the code (or the full redirect URL): " - - input = $stdin.gets.to_s.strip - code = input[/code=([^&\s]+)/, 1] || input - abort('ERROR: no code provided') if code.empty? - - puts "\nExchanging code for tokens ..." - show(post_token( - grant_type: 'authorization_code', - code: code, - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - redirect_uri: REDIRECT_URI - )) + puts "Refreshing access token against #{OAUTH.base_url} ..." + show(OAUTH.refresh(refresh_token)) + else + puts '1. Open this URL in a browser with an active Factorial session:' + puts "\n #{OAUTH.authorize_url(redirect_uri: REDIRECT_URI)}\n\n" + puts '2. Authorize the app. You will be redirected to:' + puts " #{REDIRECT_URI}/?code=XXXX" + print "\n3. Paste the code (or the full redirect URL): " + + input = $stdin.gets.to_s.strip + code = input[/code=([^&\s]+)/, 1] || input + abort('ERROR: no code provided') if code.empty? + + puts "\nExchanging code for tokens ..." + show(OAUTH.exchange_code(code, redirect_uri: REDIRECT_URI)) + end +rescue F::Api::OAuthError => e + detail = case e.body + when Hash then " — #{e.body.inspect}" + when String then " — #{e.body[0, 300]}" + else '' + end + abort("ERROR: #{e.message}#{detail}") end diff --git a/ruby/scripts/test_api.rb b/ruby/scripts/test_api.rb index dba010d..8416c9f 100755 --- a/ruby/scripts/test_api.rb +++ b/ruby/scripts/test_api.rb @@ -16,20 +16,22 @@ require_relative '../lib/factorial_api' -# Treat unset and empty-string env vars the same. -api_key = ENV.fetch('FACTORIAL_API_KEY', nil) -token = ENV.fetch('FACTORIAL_TOKEN', nil) -api_key = nil if api_key && api_key.empty? -token = nil if token && token.empty? +begin + api = F::Api.new +rescue ArgumentError => e + abort("ERROR: #{e.message}") +end -abort('ERROR: set FACTORIAL_API_KEY or FACTORIAL_TOKEN before running') unless api_key || token -warn('NOTE: both credentials set; both auth headers will be sent') if api_key && token +# Read what actually got configured, so the banner can never disagree with +# what goes on the wire. +key_set = !api.client.config.api_key['x-api-key'].nil? +token_set = !api.client.config.access_token.nil? +warn('NOTE: both credentials set; both auth headers will be sent') if key_set && token_set -api = F::Api.new(api_key: api_key, token: token) api.client.config.debugging = true if ARGV.include?('--debug') puts "Host: #{api.client.config.host}" -puts "Auth: #{token ? 'OAuth2 token' : 'API key'}" +puts "Auth: #{token_set ? 'OAuth2 token' : 'API key'}" begin response = api.teams_team.teams_teams_get diff --git a/ruby/scripts/test_auth.rb b/ruby/scripts/test_auth.rb index 933ab53..18e8138 100755 --- a/ruby/scripts/test_auth.rb +++ b/ruby/scripts/test_auth.rb @@ -77,12 +77,6 @@ args: { api_key: 'FAKE_KEY', token: 'FAKE_TOKEN' }, expect: { 'x-api-key' => 'FAKE_KEY', 'authorization' => 'Bearer FAKE_TOKEN' }, forbid: [] - }, - { - name: 'no credentials (current behaviour: request goes out unauthenticated)', - args: { api_key: nil, token: nil }, - expect: {}, - forbid: %w[authorization x-api-key] } ] @@ -102,6 +96,20 @@ end end +# No credentials: the facade must fail fast, before anything reaches the wire. +# Scrub the env fallbacks for this check — exported FACTORIAL_* credentials +# would legitimately take over otherwise. +saved = %w[FACTORIAL_API_KEY FACTORIAL_TOKEN].to_h { |name| [name, ENV.delete(name)] } +begin + F::Api.new(base_url: base_url, api_key: nil, token: nil) + failures << 'wire: no credentials' + puts ' FAIL no credentials — expected ArgumentError, got a client' +rescue ArgumentError + puts ' PASS no credentials -> ArgumentError (fail-fast, nothing sent)' +ensure + saved.each { |name, value| ENV[name] = value if value } +end + server.close # --------------------------------------------------------------------------- diff --git a/ruby/spec/factorial_api/api_spec.rb b/ruby/spec/factorial_api/api_spec.rb index 84285a1..499efce 100644 --- a/ruby/spec/factorial_api/api_spec.rb +++ b/ruby/spec/factorial_api/api_spec.rb @@ -7,68 +7,22 @@ # assertions inspect what actually went over the wire. require 'spec_helper' -require 'socket' - -# Minimal single-threaded HTTP server on a random free port. Records every -# request it receives (request line + headers) and answers with the JSON the -# `responder` block returns for the given request line. -class FakeFactorialServer - EMPTY_PAGE = '{"data":[],"meta":{"end_cursor":null,"has_next_page":false,' \ - '"has_previous_page":false,"limit":100,"total":0}}' - - attr_reader :requests - - def initialize(&responder) - @server = TCPServer.new('127.0.0.1', 0) - @responder = responder || ->(_request_line) { EMPTY_PAGE } - @requests = [] - @thread = Thread.new { serve } - end - - def base_url - "http://127.0.0.1:#{@server.addr[1]}" - end - - def stop - @server.close - @thread.join(1) - end - - private - - def serve - loop do - sock = @server.accept - request_line = sock.gets.to_s.chomp - headers = {} - while (line = sock.gets) && line != "\r\n" - key, value = line.chomp.split(': ', 2) - headers[key.downcase] = value - end - @requests << { line: request_line, headers: headers } - body = @responder.call(request_line) - sock.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" \ - "Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}") - sock.close - end - rescue IOError, Errno::EBADF - # server socket closed: shutting down - end -end +require_relative '../support/fake_factorial_server' RSpec.describe F::Api do let(:server) { FakeFactorialServer.new } after { server.stop } - # Keep FACTORIAL_BASE_URL out of the picture: the facade reads it as a - # default, so a value leaking in from the developer's shell would silently - # change what these examples exercise. + # Keep the facade's env fallbacks out of the picture: values leaking in + # from the developer's shell would silently change what these examples + # exercise (and the env-fallback examples below set their own). around do |example| - saved = ENV.delete('FACTORIAL_BASE_URL') + saved = %w[FACTORIAL_BASE_URL FACTORIAL_API_KEY FACTORIAL_TOKEN] + .to_h { |name| [name, ENV.delete(name)] } example.run ensure - saved ? ENV['FACTORIAL_BASE_URL'] = saved : ENV.delete('FACTORIAL_BASE_URL') + saved.each { |name, value| value ? ENV[name] = value : ENV.delete(name) } end def build_api(**options) @@ -103,12 +57,131 @@ def last_request ) end - # Documents current behaviour. If the facade ever gains fail-fast - # credential validation, replace this with `expect { ... }.to raise_error`. - it 'sends no auth headers when constructed without credentials' do - build_api(api_key: nil, token: nil).teams_team.teams_teams_get + # Validation-only examples construct directly: no request is ever sent, + # so there is no server to boot. + it 'fails fast when constructed without credentials' do + expect { described_class.new } + .to raise_error(ArgumentError, + /provide api_key, token, oauth, or access_token \(or set FACTORIAL_API_KEY/) + end + + it 'treats empty-string credentials (unset-but-exported env vars) as absent' do + expect { described_class.new(api_key: '', token: ' ') } + .to raise_error(ArgumentError, /provide api_key/) + end + end - expect(last_request[:headers].keys).not_to include('authorization', 'x-api-key') + describe 'env credential fallback' do + it 'falls back to env credentials only when none are passed' do + ENV['FACTORIAL_API_KEY'] = 'ENV_KEY' + + build_api.teams_team.teams_teams_get + + expect(last_request[:headers]).to include('x-api-key' => 'ENV_KEY') + end + + it 'ignores env credentials once any credential is passed explicitly' do + ENV['FACTORIAL_TOKEN'] = 'ENV_LEFTOVER' + ENV['FACTORIAL_API_KEY'] = 'ENV_KEY' + session = instance_double(F::Api::OAuth::Session) + allow(session).to receive(:access_token).and_return('SESSION') + + # Neither a "mutually exclusive" veto from the env token, nor an + # x-api-key riding along from the env key. + build_api(oauth: session).teams_team.teams_teams_get + + expect(last_request[:headers]['authorization']).to eq('Bearer SESSION') + expect(last_request[:headers]).not_to have_key('x-api-key') + end + end + + describe 'oauth session integration' do + it 'consults the session on every request: a token swap needs no client rebuild' do + session = instance_double(F::Api::OAuth::Session) + current_token = 'FIRST' + allow(session).to receive(:access_token) { current_token } + api = build_api(api_key: nil, token: nil, oauth: session) + + api.teams_team.teams_teams_get + current_token = 'SECOND' + api.teams_team.teams_teams_get + + bearers = server.requests.map { |r| r[:headers]['authorization'] } + expect(bearers).to eq(['Bearer FIRST', 'Bearer SECOND']) + expect(server.requests.last[:headers]).not_to have_key('x-api-key') + end + + it 'rejects token and oauth together' do + session = instance_double(F::Api::OAuth::Session, access_token: 'X') + + expect { described_class.new(token: 'T', oauth: session) } + .to raise_error(ArgumentError, /mutually exclusive/) + end + + it 'rejects an oauth source that does not respond to #access_token' do + expect { described_class.new(oauth: 'a-raw-token-string') } + .to raise_error(ArgumentError, /oauth must respond to #access_token/) + end + + # The composition seam: `oauth:` is duck-typed on purpose, so any token + # source can plug in without depending on this gem's Session class. + it 'accepts any token source that responds to #access_token' do + source = Class.new { def access_token = 'PLUGGED' }.new + api = build_api(oauth: source) + + api.teams_team.teams_teams_get + + expect(last_request[:headers]['authorization']).to eq('Bearer PLUGGED') + end + + it 'refuses to send a request when the token source yields no token' do + api = build_api(access_token: -> {}) + + expect { api.teams_team.teams_teams_get } + .to raise_error(RuntimeError, /returned no token/) + expect(server.requests).to be_empty + end + end + + describe 'access_token callable' do + it 'evaluates the callable on every request, so one client can serve many callers' do + current = 'FIRST' + api = build_api(api_key: nil, token: nil, access_token: -> { current }) + + api.teams_team.teams_teams_get + current = 'SECOND' + api.teams_team.teams_teams_get + + bearers = server.requests.map { |r| r[:headers]['authorization'] } + expect(bearers).to eq(['Bearer FIRST', 'Bearer SECOND']) + end + + it 'rejects a non-callable access_token, pointing at token: instead' do + expect { described_class.new(access_token: 'A-STRING') } + .to raise_error(ArgumentError, /must be callable.*use token:/) + end + + it 'rejects access_token combined with another bearer source' do + expect { described_class.new(token: 'T', access_token: -> { 'X' }) } + .to raise_error(ArgumentError, /token and access_token are mutually exclusive/) + + session = instance_double(F::Api::OAuth::Session, access_token: 'X') + expect { described_class.new(oauth: session, access_token: -> { 'X' }) } + .to raise_error(ArgumentError, /oauth and access_token are mutually exclusive/) + end + end + + describe '401 responses' do + # The 401 refresh-and-retry (see oauth_spec's end-to-end examples) is + # exclusive to sources that can mint a replacement bearer: with a static + # credential there is nothing to retry with. + it 'propagates the error without retrying when the credential cannot be refreshed' do + server.responder = ->(_l, _b) { [401, '{"error":"Unauthorized"}'] } + api = build_api(token: 'STATIC') + + expect { api.teams_team.teams_teams_get } + .to raise_error(F::Api::ApiError) { |error| expect(error.code).to eq(401) } + expect(server.requests.size).to eq(1) end end diff --git a/ruby/spec/factorial_api/oauth_spec.rb b/ruby/spec/factorial_api/oauth_spec.rb new file mode 100644 index 0000000..21855c7 --- /dev/null +++ b/ruby/spec/factorial_api/oauth_spec.rb @@ -0,0 +1,407 @@ +# frozen_string_literal: true + +# Handwritten specs for F::Api::OAuth: the /oauth/token operations, the +# self-refreshing Session, and the end-to-end integration with F::Api. +# Like api_spec, everything goes over a real TCP socket against a fake +# server, and the assertions inspect the wire. + +require 'spec_helper' +require_relative '../support/fake_factorial_server' +require_relative '../support/jwt_fixtures' + +RSpec.describe F::Api::OAuth do + include JwtFixtures + + let(:server) { FakeFactorialServer.new } + + after { server.stop } + + # Keep the OAuth-related env vars out of the picture, same rationale as the + # FACTORIAL_BASE_URL scrub in api_spec. + around do |example| + saved = %w[FACTORIAL_OAUTH_CLIENT_ID FACTORIAL_OAUTH_CLIENT_SECRET FACTORIAL_BASE_URL] + .to_h { |name| [name, ENV.delete(name)] } + example.run + ensure + saved.each { |name, value| value ? ENV[name] = value : ENV.delete(name) } + end + + def build_oauth(**options) + described_class.new(client_id: 'CLIENT', client_secret: 'SECRET', base_url: server.base_url, + **options) + end + + def token_response(access_token:, refresh_token: 'REFRESH-2', expires_in: 3600) + JSON.generate('access_token' => access_token, 'refresh_token' => refresh_token, + 'token_type' => 'Bearer', 'expires_in' => expires_in, 'scope' => 'read') + end + + def form_params(request) + URI.decode_www_form(request[:body].to_s).to_h + end + + describe '#initialize' do + it 'requires client_id and client_secret, treating empty strings as absent' do + expect { described_class.new(client_id: nil, client_secret: 'S') } + .to raise_error(ArgumentError, /provide client_id and client_secret/) + expect { described_class.new(client_id: 'ID', client_secret: ' ') } + .to raise_error(ArgumentError, /FACTORIAL_OAUTH_CLIENT_SECRET/) + end + + it 'falls back to the FACTORIAL_OAUTH_* and FACTORIAL_BASE_URL env vars' do + ENV['FACTORIAL_OAUTH_CLIENT_ID'] = 'ENV_ID' + ENV['FACTORIAL_OAUTH_CLIENT_SECRET'] = 'ENV_SECRET' + ENV['FACTORIAL_BASE_URL'] = 'http://env.example.com/' + + oauth = described_class.new + + expect(oauth.client_id).to eq('ENV_ID') + expect(oauth.base_url).to eq('http://env.example.com') # trailing slash trimmed + end + + it 'defaults the base URL to production' do + expect(described_class.new(client_id: 'ID', client_secret: 'S').base_url) + .to eq('https://api.factorialhr.com') + end + end + + describe '#authorize_url' do + it 'points at /oauth/authorize with the client_id and encoded redirect_uri' do + url = build_oauth.authorize_url(redirect_uri: 'https://myapp.com/cb?x=1') + + expect(url).to start_with("#{server.base_url}/oauth/authorize?") + expect(url).to include('client_id=CLIENT') + expect(url).to include('response_type=code') + expect(url).to include('redirect_uri=https%3A%2F%2Fmyapp.com%2Fcb%3Fx%3D1') + end + end + + describe '#exchange_code' do + it 'POSTs the authorization_code grant form-encoded and returns Tokens' do + jwt = build_jwt('exp' => Time.now.to_i + 3600, 'cid' => '42') + server.responder = ->(_line, _body) { token_response(access_token: jwt) } + + tokens = build_oauth.exchange_code('THE-CODE', redirect_uri: 'https://myapp.com/cb') + + request = server.requests.last + expect(request[:line]).to start_with('POST /oauth/token') + expect(form_params(request)).to eq( + 'grant_type' => 'authorization_code', 'code' => 'THE-CODE', + 'redirect_uri' => 'https://myapp.com/cb', + 'client_id' => 'CLIENT', 'client_secret' => 'SECRET' + ) + expect(tokens.access_token).to be_a(F::Api::Token) + expect(tokens.access_token[:cid]).to eq('42') + expect(tokens.refresh_token).to eq('REFRESH-2') + expect(tokens.expires_in).to eq(3600) + expect(tokens.expires_at).to be_within(5).of(Time.now + 3600) + expect(tokens.scope).to eq('read') + expect(tokens.token_type).to eq('Bearer') + end + end + + describe '#refresh' do + it 'POSTs the refresh_token grant' do + server.responder = ->(_line, _body) { token_response(access_token: 'NEW') } + + tokens = build_oauth.refresh('REFRESH-1') + + expect(form_params(server.requests.last)).to include( + 'grant_type' => 'refresh_token', 'refresh_token' => 'REFRESH-1' + ) + expect(tokens.access_token.raw).to eq('NEW') + end + end + + describe 'base URL with a path prefix' do + it 'keeps the prefix on the token endpoint, like authorize_url does' do + server.responder = ->(_l, _b) { token_response(access_token: 'NEW') } + oauth = build_oauth(base_url: "#{server.base_url}/sub") + + oauth.refresh('R') + + expect(server.requests.last[:line]).to start_with('POST /sub/oauth/token') + expect(oauth.authorize_url(redirect_uri: 'https://x.dev/cb')) + .to start_with("#{server.base_url}/sub/oauth/authorize?") + end + end + + describe 'error handling' do + it 'raises F::Api::OAuthError with code, body, and the OAuth error id on non-2xx' do + server.responder = lambda do |_line, _body| + [400, '{"error":"invalid_grant","error_description":"code expired"}'] + end + + expect { build_oauth.refresh('STALE') }.to raise_error(F::Api::OAuthError) do |error| + expect(error.code).to eq(400) + expect(error.error).to eq('invalid_grant') + expect(error.body).to include('error_description' => 'code expired') + expect(error.message).to include('HTTP 400 (invalid_grant): code expired') + end + end + + it 'keeps the raw body when the error response is not JSON' do + server.responder = ->(_line, _body) { [502, 'Bad Gateway', 'text/plain'] } + + expect { build_oauth.refresh('R') }.to raise_error(F::Api::OAuthError) do |error| + expect(error.code).to eq(502) + expect(error.body).to eq('Bad Gateway') + expect(error.error).to be_nil + end + end + + it 'rejects a 2xx whose body is not a JSON object' do + server.responder = ->(_line, _body) { '"unexpected"' } + + expect { build_oauth.refresh('R') }.to raise_error(F::Api::OAuthError) { |e| expect(e.code).to eq(200) } + end + + it 'rejects a 2xx JSON object that carries no access_token' do + server.responder = ->(_line, _body) { '{"error":"try_again_later"}' } + + expect { build_oauth.refresh('R') }.to raise_error(F::Api::OAuthError) do |error| + expect(error.code).to eq(200) + end + end + + it 'wraps an unparseable JSON body instead of leaking JSON::ParserError' do + server.responder = ->(_line, _body) { [400, 'not-json'] } # Content-Type: application/json + + expect { build_oauth.refresh('R') }.to raise_error(F::Api::OAuthError) do |error| + expect(error.code).to be_nil + expect(error.message).to include('unparseable') + end + end + end + + describe 'F::Api::OAuth::Session' do + let(:fresh_jwt) { build_jwt('exp' => Time.now.to_i + 3600) } + let(:stale_jwt) { build_jwt('exp' => Time.now.to_i + 5) } + let(:rotations) { [] } + + def tokens_with(access_token, refresh_token: 'REFRESH-1', **extra) + F::Api::OAuth::Tokens.new({ 'access_token' => access_token, + 'refresh_token' => refresh_token }.merge(extra)) + end + + def build_session(oauth, tokens, **options) + oauth.session(tokens, **options) { |rotated| rotations << rotated } + end + + it 'requires the rotation block' do + expect { build_oauth.session(tokens_with(fresh_jwt)) } + .to raise_error(ArgumentError, /rotation block/) + end + + it 'returns a fresh token as-is, without calling the token endpoint' do + session = build_session(build_oauth, tokens_with(fresh_jwt)) + + expect(session.access_token).to eq(fresh_jwt) + expect(server.requests).to be_empty + end + + # tokens_with builds sets without expires_in, so the refresh examples + # exercise the JWT-exp fallback path. + it 'refreshes a token inside the margin, rotates, and keeps the new set' do + server.responder = ->(_l, _b) { token_response(access_token: fresh_jwt) } + session = build_session(build_oauth, tokens_with(stale_jwt)) + + expect(session.access_token).to eq(fresh_jwt) + expect(form_params(server.requests.last)).to include('refresh_token' => 'REFRESH-1') + expect(rotations.map(&:refresh_token)).to eq(['REFRESH-2']) + expect(session.tokens.refresh_token).to eq('REFRESH-2') + + # The refreshed token is fresh, so the next call goes straight through. + expect { session.access_token }.not_to(change { server.requests.size }) + end + + it 'honours a custom margin' do + soonish = build_jwt('exp' => Time.now.to_i + 100) + server.responder = ->(_l, _b) { token_response(access_token: 'NEW') } + + expect(build_session(build_oauth, tokens_with(soonish)).access_token).to eq(soonish) + expect(build_session(build_oauth, tokens_with(soonish), margin: 200).access_token).to eq('NEW') + end + + it 'refreshes at the earliest known expiry, whichever clock says sooner' do + server.responder = ->(_l, _b) { token_response(access_token: fresh_jwt) } + oauth = build_oauth + + # The JWT looks fresh, but the endpoint said it dies in 10s: refresh. + build_session(oauth, tokens_with(fresh_jwt, 'expires_in' => 10)).access_token + expect(server.requests.size).to eq(1) + + # The endpoint granted an hour, but the JWT exp is in 5s: refresh too — + # serving a token past its exp is never right, whatever expires_in said. + build_session(oauth, tokens_with(stale_jwt, 'expires_in' => 3600)).access_token + expect(server.requests.size).to eq(2) + + # Both clocks fresh: no refresh. + sound = build_session(oauth, tokens_with(fresh_jwt, 'expires_in' => 3600)) + expect(sound.access_token).to eq(fresh_jwt) + expect(server.requests.size).to eq(2) + end + + it 'keeps the previous refresh token when the endpoint does not rotate it' do + server.responder = ->(_l, _b) { token_response(access_token: fresh_jwt, refresh_token: nil) } + session = build_session(build_oauth, tokens_with(stale_jwt)) + + session.access_token + + expect(session.tokens.refresh_token).to eq('REFRESH-1') + expect(rotations.map(&:refresh_token)).to eq(['REFRESH-1']) + end + + it 'has no expires_at when the endpoint sent no expires_in' do + expect(tokens_with(fresh_jwt).expires_at).to be_nil + end + + it 'returns a stale token as-is when there is no refresh token' do + session = build_session(build_oauth, tokens_with(stale_jwt, refresh_token: nil)) + + expect(session.access_token).to eq(stale_jwt) + expect(server.requests).to be_empty + end + + it 'never refreshes an opaque (non-JWT) token' do + session = build_session(build_oauth, tokens_with('opaque-token')) + + expect(session.access_token).to eq('opaque-token') + expect(server.requests).to be_empty + end + + it 'propagates refresh failures as F::Api::OAuthError' do + server.responder = ->(_l, _b) { [400, '{"error":"invalid_grant"}'] } + session = build_session(build_oauth, tokens_with(stale_jwt)) + + expect { session.access_token }.to raise_error(F::Api::OAuthError, /invalid_grant/) + expect(rotations).to be_empty + end + + describe '#refresh_after_reject!' do + it 'refreshes when the rejected token is still current and reports a retry is worth it' do + server.responder = ->(_l, _b) { token_response(access_token: fresh_jwt) } + session = build_session(build_oauth, tokens_with('revoked-token')) + + expect(session.refresh_after_reject!('revoked-token')).to be(true) + expect(session.tokens.access_token.raw).to eq(fresh_jwt) + expect(rotations.map(&:refresh_token)).to eq(['REFRESH-2']) + end + + it 'refreshes only once when concurrent requests reject the same token' do + server.responder = ->(_l, _b) { token_response(access_token: fresh_jwt) } + session = build_session(build_oauth, tokens_with('revoked-token')) + + expect(session.refresh_after_reject!('revoked-token')).to be(true) + # The second rejection arrives late: the token it saw die is already + # replaced, so hitting the endpoint again would burn the single-use + # refresh token the first refresh just rotated in. + expect(session.refresh_after_reject!('revoked-token')).to be(true) + expect(server.requests.size).to eq(1) + end + + it 'reports nothing to retry with when the session has no refresh token' do + session = build_session(build_oauth, tokens_with('revoked-token', refresh_token: nil)) + + expect(session.refresh_after_reject!('revoked-token')).to be(false) + expect(server.requests).to be_empty + end + end + end + + describe 'end-to-end with F::Api' do + let(:rotations) { [] } + + # A real Session over the fake server, its rotation block recording into + # `rotations` — the same shape an integrator writes. + def live_session(access_token) + build_oauth.session(F::Api::OAuth::Tokens.new('access_token' => access_token, + 'refresh_token' => 'REFRESH-1')) do |t| + rotations << t.refresh_token + end + end + + it 'refreshes mid-flight: the API request carries the rotated bearer' do + fresh_jwt = build_jwt('exp' => Time.now.to_i + 3600) + stale_jwt = build_jwt('exp' => Time.now.to_i + 5) + server.responder = lambda do |line, _body| + if line.start_with?('POST /oauth/token') + token_response(access_token: fresh_jwt) + else + FakeFactorialServer::EMPTY_PAGE + end + end + session = live_session(stale_jwt) + + F::Api.new(oauth: session, base_url: server.base_url) + .teams_team.teams_teams_get + + token_request, api_request = server.requests + expect(token_request[:line]).to start_with('POST /oauth/token') + expect(api_request[:headers]['authorization']).to eq("Bearer #{fresh_jwt}") + expect(api_request[:headers]).not_to have_key('x-api-key') + expect(rotations).to eq(['REFRESH-2']) + end + + it 'retries once after a 401: revocation is invisible to expiry, visible to the API' do + revoked_jwt = build_jwt('exp' => Time.now.to_i + 3600) # fresh by every clock + fresh_jwt = build_jwt('exp' => Time.now.to_i + 7200) + server.responder = lambda do |line, _body| + next token_response(access_token: fresh_jwt) if line.start_with?('POST /oauth/token') + + # Judge by the bearer actually on the wire, like real revocation does + # (the server records a request before answering it). + if server.requests.last[:headers]['authorization'] == "Bearer #{fresh_jwt}" + FakeFactorialServer::EMPTY_PAGE + else + [401, '{"error":"Unauthorized"}'] + end + end + + result = F::Api.new(oauth: live_session(revoked_jwt), base_url: server.base_url) + .teams_team.teams_teams_get + + expect(result.data).to eq([]) + expect(server.requests.size).to eq(3) + expect(server.requests[1][:line]).to start_with('POST /oauth/token') + expect(server.requests.first[:headers]['authorization']).to eq("Bearer #{revoked_jwt}") + expect(server.requests.last[:headers]['authorization']).to eq("Bearer #{fresh_jwt}") + expect(rotations).to eq(['REFRESH-2']) + end + + it 'gives up after one retry when the API keeps rejecting the bearer' do + fresh_jwt = build_jwt('exp' => Time.now.to_i + 7200) + server.responder = lambda do |line, _body| + if line.start_with?('POST /oauth/token') + token_response(access_token: fresh_jwt) + else + [401, '{"error":"Unauthorized"}'] + end + end + api = F::Api.new(oauth: live_session(build_jwt('exp' => Time.now.to_i + 3600)), + base_url: server.base_url) + + expect { api.teams_team.teams_teams_get } + .to raise_error(F::Api::ApiError) { |error| expect(error.code).to eq(401) } + token_requests = server.requests.count { |r| r[:line].start_with?('POST /oauth/token') } + expect(token_requests).to eq(1) + expect(server.requests.size).to eq(3) + end + + it 'surfaces a refresh that fails after a 401 as F::Api::OAuthError' do + server.responder = lambda do |line, _body| + if line.start_with?('POST /oauth/token') + [400, '{"error":"invalid_grant"}'] + else + [401, '{"error":"Unauthorized"}'] + end + end + api = F::Api.new(oauth: live_session(build_jwt('exp' => Time.now.to_i + 3600)), + base_url: server.base_url) + + expect { api.teams_team.teams_teams_get } + .to raise_error(F::Api::OAuthError, /invalid_grant/) + expect(rotations).to be_empty + end + end +end diff --git a/ruby/spec/factorial_api/token_spec.rb b/ruby/spec/factorial_api/token_spec.rb new file mode 100644 index 0000000..5c0d679 --- /dev/null +++ b/ruby/spec/factorial_api/token_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +# Handwritten specs for F::Api::Token. The JWTs are built by hand (base64url +# segments joined with dots, fake signature) so every claim set — and every +# malformed variant — is fully controlled by the example that uses it. + +require 'spec_helper' +require_relative '../support/jwt_fixtures' + +RSpec.describe F::Api::Token do + include JwtFixtures + + describe '#claims' do + it 'decodes the payload of a well-formed JWT, string-keyed' do + token = described_class.new(build_jwt('cid' => '42', 'staff' => true)) + + expect(token.claims).to eq('cid' => '42', 'staff' => true) + expect(token.jwt?).to be(true) + end + + it 'decodes payloads of every base64 padding length' do + %w[a ab abc].each do |value| + token = described_class.new(build_jwt('v' => value)) + + expect(token.claims).to eq('v' => value) + end + end + + it 'decodes the base64url alphabet (-_ instead of +/)' do + # eyJ4IjoiPz8_PiJ9 is base64url for {"x":"???>"} and contains a `_` + # that plain base64 decoding would reject. + token = described_class.new("#{encode_segment(alg: 'none')}.eyJ4IjoiPz8_PiJ9.sig") + + expect(token.claims).to eq('x' => '???>') + end + + it 'is empty for an opaque (non-JWT) credential' do + token = described_class.new('opaque-credential') + + expect(token.claims).to eq({}) + expect(token.jwt?).to be(false) + end + + it 'is empty for nil' do + token = described_class.new(nil) + + expect(token.raw).to eq('') + expect(token.claims).to eq({}) + end + + it 'is empty when the segment count is not three' do + two = "#{encode_segment(alg: 'none')}.#{encode_segment(exp: 1)}" + + expect(described_class.new(two).claims).to eq({}) + expect(described_class.new("#{two}.sig.extra").claims).to eq({}) + end + + it 'is empty when the payload is not decodable JSON' do + expect(described_class.new('head.!!!not-base64!!!.sig').claims).to eq({}) + expect(described_class.new('head.bm90LWpzb24.sig').claims).to eq({}) # "not-json" + end + + it 'is empty when the payload is JSON but not an object' do + expect(described_class.new('head.WzFd.sig').claims).to eq({}) # [1] + end + + it 'is frozen, like the token itself' do + token = described_class.new(build_jwt('cid' => '42')) + + expect(token).to be_frozen + expect(token.claims).to be_frozen + end + end + + describe '#expires_at' do + it 'returns the exp claim as a UTC Time' do + token = described_class.new(build_jwt('exp' => 1_767_225_600)) + + expect(token.expires_at).to eq(Time.at(1_767_225_600).utc) + expect(token.expires_at.utc?).to be(true) + end + + it 'is nil when exp is absent, non-numeric, or the token is opaque' do + expect(described_class.new(build_jwt('cid' => '42')).expires_at).to be_nil + expect(described_class.new(build_jwt('exp' => 'soon')).expires_at).to be_nil + expect(described_class.new('opaque').expires_at).to be_nil + end + end + + describe '#expired?' do + let(:now) { Time.at(1_000_000) } + + it 'is true from the exp instant onwards' do + expect(described_class.new(build_jwt('exp' => now.to_i - 1)).expired?(now: now)).to be(true) + expect(described_class.new(build_jwt('exp' => now.to_i)).expired?(now: now)).to be(true) + end + + it 'is false before exp' do + expect(described_class.new(build_jwt('exp' => now.to_i + 1)).expired?(now: now)).to be(false) + end + + it 'is false without a readable expiry (the API stays the authority)' do + expect(described_class.new(build_jwt('cid' => '42')).expired?(now: now)).to be(false) + expect(described_class.new('opaque').expired?(now: now)).to be(false) + end + end + + describe '#expiring_soon?' do + let(:now) { Time.at(1_000_000) } + + it 'is true when exp falls within the margin' do + token = described_class.new(build_jwt('exp' => now.to_i + 100)) + + expect(token.expiring_soon?(margin: 100, now: now)).to be(true) + expect(token.expiring_soon?(margin: 99, now: now)).to be(false) + end + + it 'defaults to a 60-second margin' do + expect(described_class.new(build_jwt('exp' => now.to_i + 59)).expiring_soon?(now: now)).to be(true) + expect(described_class.new(build_jwt('exp' => now.to_i + 61)).expiring_soon?(now: now)).to be(false) + end + + it 'is false without a readable expiry' do + expect(described_class.new('opaque').expiring_soon?(now: now)).to be(false) + end + end + + describe '#[]' do + it 'reads a claim by string or symbol' do + token = described_class.new(build_jwt('cid' => '42')) + + expect(token['cid']).to eq('42') + expect(token[:cid]).to eq('42') + expect(token[:missing]).to be_nil + end + end + + describe '#to_s / #inspect' do + it 'round-trips the raw credential through to_s' do + jwt = build_jwt('cid' => '42') + + expect(described_class.new(jwt).to_s).to eq(jwt) + expect(described_class.new(jwt).raw).to eq(jwt) + end + + it 'keeps the raw credential out of inspect' do + jwt = build_jwt('cid' => '42') + token = described_class.new(jwt) + + expect(token.inspect).to include('"cid"=>"42"').or include('"cid" => "42"') + expect(token.inspect).not_to include(jwt) + end + end +end diff --git a/ruby/spec/support/fake_factorial_server.rb b/ruby/spec/support/fake_factorial_server.rb new file mode 100644 index 0000000..236d338 --- /dev/null +++ b/ruby/spec/support/fake_factorial_server.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'socket' + +# Minimal single-threaded HTTP server on a random free port. Records every +# request it receives (request line + headers + body) and answers with +# whatever the `responder` block returns for it: a JSON string (served as +# 200), a [status, body] pair, or [status, body, content_type] for non-JSON +# responses. +class FakeFactorialServer + EMPTY_PAGE = '{"data":[],"meta":{"end_cursor":null,"has_next_page":false,' \ + '"has_previous_page":false,"limit":100,"total":0}}' + + attr_reader :requests + # Swappable per example, so one shared server can serve many responders. + attr_writer :responder + + def initialize(&responder) + @server = TCPServer.new('127.0.0.1', 0) + @responder = responder || ->(_request_line, _body) { EMPTY_PAGE } + @requests = [] + @thread = Thread.new { serve } + end + + def base_url + "http://127.0.0.1:#{@server.addr[1]}" + end + + def stop + @server.close + @thread.join(1) + end + + private + + def serve + loop { handle(@server.accept) } + rescue IOError, Errno::EBADF + # server socket closed: shutting down + end + + def handle(sock) + request_line = sock.gets.to_s.chomp + headers = {} + while (line = sock.gets) && line != "\r\n" + key, value = line.chomp.split(': ', 2) + headers[key.downcase] = value + end + body = headers['content-length'] ? sock.read(headers['content-length'].to_i) : nil + @requests << { line: request_line, headers: headers, body: body } + + status, payload, content_type = response_for(request_line, body) + sock.write("HTTP/1.1 #{status} Status\r\nContent-Type: #{content_type}\r\n" \ + "Content-Length: #{payload.bytesize}\r\nConnection: close\r\n\r\n#{payload}") + sock.close + end + + def response_for(request_line, body) + status, payload, content_type = @responder.call(request_line, body) + .then { |result| result.is_a?(Array) ? result : [200, result] } + [status, payload.to_s, content_type || 'application/json'] + end +end diff --git a/ruby/spec/support/jwt_fixtures.rb b/ruby/spec/support/jwt_fixtures.rb new file mode 100644 index 0000000..f394a7e --- /dev/null +++ b/ruby/spec/support/jwt_fixtures.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'json' + +# Builds JWTs by hand — base64url segments joined with dots, fake signature — +# exactly like a real producer would, so every claim set (and every malformed +# variant) is fully controlled by the example that uses it. +module JwtFixtures + # Encodes exactly like a real JWT producer: base64url alphabet, no padding. + def encode_segment(hash) + [JSON.generate(hash)].pack('m0').tr('+/', '-_').delete('=') + end + + def build_jwt(claims) + "#{encode_segment(alg: 'none')}.#{encode_segment(claims)}.fake-signature" + end +end