standard_singpass 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8da1f0678a358ee51cf45c1f7b47f537e465f4d9c2ed738de7cdb2326fd92b49
4
- data.tar.gz: cd6df85e85fe8d5a0636558b943f1cfadc5a5704eed15c7c52c367c2bf7ba974
3
+ metadata.gz: ea90960df9e3752b2ec01cb271cac1c31fc8c53cfb5a757a64810d3eccecbb79
4
+ data.tar.gz: 3eba17716f557250f1013b303216b2450ea691080ed7bc924b8fa1749b832305
5
5
  SHA512:
6
- metadata.gz: 849a2c6e1de1e63c8092f2088ef5e8de12292d3b3e130c6a47831fa244def9dfe7cc854a82eff49d4863757423d4feb788f6d943345aa953c6007a85f91df549
7
- data.tar.gz: 36da035a4471275f50a3a2e2c974f440ff337c45a34b43a4181da80187a283c40937bcc0724f20a52d8f9a22780d6af277aba3494739bdaa49ebe586a193caee
6
+ metadata.gz: e3f7ca50aaf67da5e1b82f33455662da9743a9f4cffedd533a878c409394681b7f1e1d16c197362cccce9031744509c71e6674195b1012ee7f7867595fa4cd48
7
+ data.tar.gz: bba22e4c5ede26f234acfea6289ee469fa646612bf198eabcaacc397e3270ce276fd5d3ea18adf9c87b549a0d88ab3b905baa15b7692de1666eaebae0ba58d8d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-07-22
11
+
12
+ ### Added
13
+
14
+ - `StandardSingpass::Myinfo::ApiError#status` — the HTTP status of the Myinfo response that produced the error (`nil` for transport failures and for errors a host raises itself). Hosts need this to separate "Myinfo or one of its upstream agencies is unavailable" (502/503/504 — tell the user to retry shortly) from "we sent something wrong" (4xx — a bug worth surfacing to support). Previously the only way to recover the status was to parse it back out of the message, which is not a stable interface. Set on both the userinfo and token-exchange failure paths.
15
+ - Bounded retry with jittered exponential backoff on the **userinfo** fetch for transient upstream statuses (502/503/504), 3 attempts total. Singpass returns `502 upstream_dependency_error` whenever a Myinfo upstream (CPF Board, IRAS, MOM, …) is unavailable — including throughout their [published maintenance windows](https://docs.developer.singpass.gov.sg/docs/products/singpass-myinfo/scheduled-downtimes) — and a single transient one previously failed the whole flow. Because the authorization code is already spent by that point, the user's only recovery was a **full Singpass re-login**, so a momentary blip cost a complete re-authentication.
16
+
17
+ ### Notes
18
+
19
+ - The retry is scoped deliberately:
20
+ - **Userinfo only.** The token exchange is never retried — it consumes a single-use authorization code, and replaying it earns an `invalid_grant`. Userinfo is an idempotent GET against an access token already in hand.
21
+ - **Status-based failures only, never timeouts.** A 5xx returns in well under a second; a timed-out attempt has already spent the request budget, and retrying it risks turning a slow page into a gateway error.
22
+ - **Inside `network_wrapper`, not outside.** A host's wrapper is typically a circuit breaker; keeping retries inside means one user attempt counts as one failure against the breaker rather than three, so a single unlucky user can't trip a shared circuit alone.
23
+ - The DPoP proof is rebuilt on every attempt — RFC 9449 proofs carry a one-shot `jti`, so a replayed header would be rejected.
24
+
10
25
  ## [0.1.0] - 2026-05-24
11
26
 
12
27
  ### Added
@@ -1,4 +1,6 @@
1
- Copyright (c) 2026 Jaryl Sim
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Rarebit One
2
4
 
3
5
  Permission is hereby granted, free of charge, to any person obtaining
4
6
  a copy of this software and associated documentation files (the
@@ -169,17 +169,25 @@ module StandardSingpass
169
169
 
170
170
  sig { params(access_token: String, dpop_key_pair: OpenSSL::PKey::EC).returns(T::Hash[String, T.untyped]) }
171
171
  def fetch_userinfo(access_token:, dpop_key_pair:)
172
+ # Retries sit INSIDE the network wrapper deliberately. A host's wrapper
173
+ # is typically a circuit breaker; keeping retries inside means one user
174
+ # attempt counts as one failure against the breaker instead of three,
175
+ # so a single unlucky borrower can't trip a shared circuit on their own.
172
176
  with_network_wrapper do
173
- response = http_connection.get(@userinfo_url) do |req|
174
- req.headers["Authorization"] = "DPoP #{access_token}"
175
- req.headers["DPoP"] = Security.build_dpop_proof(
176
- http_method: "GET",
177
- url: T.must(@userinfo_url),
178
- key_pair: dpop_key_pair,
179
- access_token:
180
- )
177
+ with_userinfo_retries do
178
+ # The DPoP proof carries a per-request jti/iat, so it is rebuilt on
179
+ # every attempt — replaying one would be rejected as a duplicate.
180
+ response = http_connection.get(@userinfo_url) do |req|
181
+ req.headers["Authorization"] = "DPoP #{access_token}"
182
+ req.headers["DPoP"] = Security.build_dpop_proof(
183
+ http_method: "GET",
184
+ url: T.must(@userinfo_url),
185
+ key_pair: dpop_key_pair,
186
+ access_token:
187
+ )
188
+ end
189
+ handle_person_response(response, jwks_url: @userinfo_jwks_url)
181
190
  end
182
- handle_person_response(response, jwks_url: @userinfo_jwks_url)
183
191
  end
184
192
  rescue Faraday::Error => e
185
193
  raise ApiError, "MyInfo userinfo endpoint unreachable: #{e.class}"
@@ -332,7 +340,10 @@ module StandardSingpass
332
340
  when 429
333
341
  raise RateLimitError, "Token endpoint rate limit exceeded"
334
342
  else
335
- raise ApiError, "Token exchange failed (HTTP #{response.status}): #{body_excerpt(response)}"
343
+ raise ApiError.new(
344
+ "Token exchange failed (HTTP #{response.status}): #{body_excerpt(response)}",
345
+ status: response.status
346
+ )
336
347
  end
337
348
  rescue KeyError
338
349
  raise AuthenticationError, "Token response missing access_token"
@@ -350,10 +361,60 @@ module StandardSingpass
350
361
  when 429
351
362
  raise RateLimitError, "Person endpoint rate limit exceeded"
352
363
  else
353
- raise ApiError, "Person data fetch failed (HTTP #{response.status}): #{body_excerpt(response)}"
364
+ raise ApiError.new(
365
+ "Person data fetch failed (HTTP #{response.status}): #{body_excerpt(response)}",
366
+ status: response.status
367
+ )
368
+ end
369
+ end
370
+
371
+ # Myinfo statuses that mean "Myinfo, or one of its upstream agencies, is
372
+ # having a moment" — transient and worth one more try. 502 is Singpass's
373
+ # documented upstream-dependency signal (`upstream_dependency_error`),
374
+ # which they also return throughout CPF/IRAS/MOM maintenance windows.
375
+ RETRYABLE_USERINFO_STATUSES = T.let([502, 503, 504].freeze, T::Array[Integer])
376
+
377
+ # Total attempts, not retries — 3 means the original plus two more.
378
+ USERINFO_MAX_ATTEMPTS = 3
379
+
380
+ # Base for the exponential backoff, in seconds (0.3s, then 0.6s, each
381
+ # with jitter). Deliberately small: this runs inside a browser redirect
382
+ # from Singpass, so the whole retry budget has to stay well under the
383
+ # user's patience and the host's request timeout.
384
+ USERINFO_RETRY_BASE_DELAY = 0.3
385
+
386
+ # Retry a userinfo fetch that failed with a transient upstream status.
387
+ #
388
+ # Safe to retry because the userinfo call is an idempotent GET against an
389
+ # access token we already hold — unlike the token exchange, which burns a
390
+ # single-use authorization code and must never be replayed.
391
+ #
392
+ # Only status-based failures are retried, never timeouts: a 5xx comes back
393
+ # in well under a second, whereas a timed-out attempt has already spent
394
+ # the request budget and retrying it risks turning a slow page into a
395
+ # gateway error. A transport failure therefore propagates on the first hit.
396
+ sig { params(block: T.proc.returns(T::Hash[String, T.untyped])).returns(T::Hash[String, T.untyped]) }
397
+ def with_userinfo_retries(&block)
398
+ attempt = 0
399
+ begin
400
+ attempt += 1
401
+ block.call
402
+ rescue ApiError => e
403
+ raise unless RETRYABLE_USERINFO_STATUSES.include?(e.status)
404
+ raise if attempt >= USERINFO_MAX_ATTEMPTS
405
+
406
+ sleep(userinfo_retry_delay(attempt))
407
+ retry
354
408
  end
355
409
  end
356
410
 
411
+ # Exponential backoff with full jitter, so concurrent borrowers hitting
412
+ # the same upstream blip don't retry in lockstep.
413
+ sig { params(attempt: Integer).returns(Float) }
414
+ def userinfo_retry_delay(attempt)
415
+ Float(rand * USERINFO_RETRY_BASE_DELAY * (2**(attempt - 1)))
416
+ end
417
+
357
418
  # Standardized FAPI/OAuth error fields that are safe to surface in error
358
419
  # messages — non-PII, useful for debugging, and explicitly named by the
359
420
  # OAuth 2.0 / FAPI 2.0 / Singpass specs. Any other field in the response
@@ -4,11 +4,40 @@ module StandardSingpass
4
4
  module Myinfo
5
5
  class Error < StandardError; end
6
6
  class AuthenticationError < Error; end
7
- class ApiError < Error; end
8
7
  class PARError < Error; end
9
8
  class DecryptionError < Error; end
10
9
  class SignatureError < Error; end
11
10
  class RateLimitError < Error; end
12
11
  class ConfigurationError < Error; end
12
+
13
+ # Raised for a Myinfo response we can't use, and for transport failures
14
+ # reaching Myinfo at all.
15
+ #
16
+ # `status` carries the HTTP status when the error came from an actual
17
+ # response (nil for transport failures, and for errors a host application
18
+ # raises itself). Hosts need it to tell "Myinfo or one of its upstream
19
+ # agencies is unavailable" — 502/503/504, retry in a few minutes — apart
20
+ # from "we sent something wrong", which is a bug and warrants support
21
+ # contact. Parsing the status back out of `message` is the alternative, and
22
+ # it is a trap: the message is not a stable interface.
23
+ #
24
+ # 502 specifically is Singpass's documented signal that a Myinfo upstream
25
+ # (CPF Board, IRAS, MOM, …) is down, including during their published
26
+ # maintenance windows:
27
+ # https://docs.developer.singpass.gov.sg/docs/products/singpass-myinfo/scheduled-downtimes
28
+ class ApiError < Error
29
+ extend T::Sig
30
+
31
+ sig { returns(T.nilable(Integer)) }
32
+ attr_reader :status
33
+
34
+ # `status:` is keyword-only and optional so the bare `raise ApiError,
35
+ # "msg"` form used across the client (and by hosts) keeps working.
36
+ sig { params(message: T.nilable(String), status: T.nilable(Integer)).void }
37
+ def initialize(message = nil, status: nil)
38
+ super(message)
39
+ @status = status
40
+ end
41
+ end
13
42
  end
14
43
  end
@@ -1,3 +1,3 @@
1
1
  module StandardSingpass
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_singpass
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -132,7 +132,7 @@ extensions: []
132
132
  extra_rdoc_files: []
133
133
  files:
134
134
  - CHANGELOG.md
135
- - MIT-LICENSE
135
+ - LICENSE
136
136
  - README.md
137
137
  - Rakefile
138
138
  - fixtures/myinfo-personas.json