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 +4 -4
- data/CHANGELOG.md +15 -0
- data/{MIT-LICENSE → LICENSE} +3 -1
- data/lib/standard_singpass/myinfo/client.rb +72 -11
- data/lib/standard_singpass/myinfo/error.rb +30 -1
- data/lib/standard_singpass/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea90960df9e3752b2ec01cb271cac1c31fc8c53cfb5a757a64810d3eccecbb79
|
|
4
|
+
data.tar.gz: 3eba17716f557250f1013b303216b2450ea691080ed7bc924b8fa1749b832305
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/{MIT-LICENSE → LICENSE}
RENAMED
|
@@ -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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
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
|
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.
|
|
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
|
-
-
|
|
135
|
+
- LICENSE
|
|
136
136
|
- README.md
|
|
137
137
|
- Rakefile
|
|
138
138
|
- fixtures/myinfo-personas.json
|