ohauthcat 0.0.2

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.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OhAuthCat
4
+ # Headless end-user passkey helpers: session sign-in and passkey rename.
5
+ class PasskeyHelpers
6
+ # The session a passkey sign-in created: the token set issued by the login host,
7
+ # audience the client ID, as /user_management/token would return it.
8
+ PasskeySession = Struct.new(:authentication, :session_id, :access_token, :refresh_token, :id_token,
9
+ :token_type, :expires_in, keyword_init: true)
10
+
11
+ def initialize(client)
12
+ @client = client
13
+ end
14
+
15
+ # Trims value and raises request_invalid when it is blank, longer than maximum
16
+ # characters or contains control characters.
17
+ def self.bounded(value, maximum)
18
+ raise Error.client("request_invalid") unless value.is_a?(String)
19
+
20
+ text = value.strip
21
+ raise Error.client("request_invalid") if text.empty? || text.length > maximum || text.match?(/[\x00-\x1f\x7f]/)
22
+
23
+ text
24
+ end
25
+
26
+ # Verifies a WebAuthn assertion (the decoded JSON credential) and creates a session for
27
+ # client_id (the configured publishable key by default). When the environment requires
28
+ # MFA and the assertion was not user-verified, the API answers mfa_required instead.
29
+ def authenticate_with_session(credential, client_id: nil)
30
+ raise Error.client("request_invalid") unless credential.is_a?(Hash)
31
+
32
+ audience = client_id.nil? ? @client.client_id : self.class.bounded(client_id, 256)
33
+ raise Error.client("request_invalid") if audience.nil?
34
+
35
+ body = { "credential" => credential, "session" => true, "client_id" => audience }
36
+ result = Models::EndUserPasskeyAuthentication.from_json(@client.request("authenticateEndUserPasskey", [], nil, body))
37
+ tokens = [result.session_id, result.access_token, result.refresh_token, result.id_token, result.expires_in]
38
+ raise Error.client("passkey_session_missing") if tokens.any?(&:nil?)
39
+
40
+ PasskeySession.new(authentication: result, session_id: result.session_id, access_token: result.access_token,
41
+ refresh_token: result.refresh_token, id_token: result.id_token, token_type: "Bearer",
42
+ expires_in: result.expires_in)
43
+ end
44
+
45
+ # Renames one of the user's passkeys (trimmed, 1 to 64 characters, no control characters).
46
+ # Another user's passkey answers resource_not_found.
47
+ def rename_passkey(user, passkey, name)
48
+ path = [self.class.bounded(user, 64), self.class.bounded(passkey, 64)]
49
+ body = { "name" => self.class.bounded(name, 64) }
50
+ Models::EndUserFactor.from_json(@client.request("updateEndUserPasskey", path, nil, body))
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OhAuthCat
4
+ # One API operation from the pinned spec.
5
+ Route = Struct.new(:name, :method, :path, :server, :auth, :media, :status, :idempotent_key, :list, keyword_init: true)
6
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OhAuthCat
4
+ # SSO sign-in helpers: build the authorization URL with PKCE and exchange the code.
5
+ class SsoHelpers
6
+ VERIFIER = /\A[A-Za-z0-9._~-]{43,128}\z/
7
+ DOMAIN = /\A(?=.{1,253}\z)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}\z/
8
+ ORG = /\Aorg_[0-9A-HJKMNP-TV-Z]{26}\z/
9
+ CONN = /\Aconn_[0-9A-HJKMNP-TV-Z]{26}\z/
10
+
11
+ def initialize(client)
12
+ @client = client
13
+ end
14
+
15
+ # RFC 7636 S256 challenge for a verifier.
16
+ def self.pkce_challenge(verifier)
17
+ raise Error.client("request_invalid") unless verifier.is_a?(String) && verifier.match?(VERIFIER)
18
+
19
+ [OpenSSL::Digest::SHA256.digest(verifier)].pack("m0").tr("+/", "-_").delete("=")
20
+ end
21
+
22
+ # Returns { url:, code_verifier:, state: }. Keep the verifier and state server-side;
23
+ # compare the callback state in constant time before the exchange. An empty state
24
+ # is replaced with a random one.
25
+ def get_authorization_url(redirect_uri:, state: "", organization: nil, connection: nil, domain: nil, login_hint: nil)
26
+ invalid = Error.client("request_invalid")
27
+ raise invalid if @client.client_id.nil?
28
+ raise invalid unless redirect_uri.is_a?(String) && !redirect_uri.empty? && redirect_uri.length <= 2048 && !redirect_uri.include?("#")
29
+
30
+ begin
31
+ uri = URI.parse(redirect_uri)
32
+ rescue URI::InvalidURIError
33
+ raise invalid
34
+ end
35
+ raise invalid unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty? && uri.userinfo.nil?
36
+ raise invalid unless state.is_a?(String) && state.length <= 1024
37
+ raise invalid if organization && !organization.match?(ORG)
38
+ raise invalid if connection && !connection.match?(CONN)
39
+ raise invalid if domain && !domain.strip.match?(DOMAIN)
40
+
41
+ if login_hint
42
+ hint = login_hint.strip
43
+ parts = hint.split("@", -1)
44
+ raise invalid if hint.length > 320 || parts.length != 2 || parts[0].empty? || hint.match?(/[\s<>"]/) || !parts[1].match?(DOMAIN)
45
+ end
46
+ state = SecureRandom.urlsafe_base64(32, false) if state.empty?
47
+ verifier = SecureRandom.urlsafe_base64(32, false)
48
+ query = { "client_id" => @client.client_id, "redirect_uri" => redirect_uri, "response_type" => "code", "state" => state,
49
+ "code_challenge" => self.class.pkce_challenge(verifier), "code_challenge_method" => "S256" }
50
+ query["organization"] = organization if organization
51
+ query["connection"] = connection if connection
52
+ query["domain"] = domain if domain
53
+ query["login_hint"] = login_hint if login_hint
54
+ { url: "#{@client.standby.snapshot(true).origin}/sso/authorize?#{URI.encode_www_form(query)}", code_verifier: verifier, state: state }
55
+ end
56
+
57
+ # Exchanges the code once (never retried). Raises RestartSignInError when the user must sign in again.
58
+ def get_profile_and_token(code:, code_verifier:, redirect_uri:)
59
+ raise Error.client("request_invalid", "client_id") if @client.client_id.nil?
60
+ raise Error.client("request_invalid", "api_key") unless @client.api_key.start_with?("sk_")
61
+ raise Error.client("request_invalid", "code") unless code.is_a?(String) && !code.empty? && code.length <= 4096
62
+ raise Error.client("request_invalid", "redirect_uri") unless redirect_uri.is_a?(String) && !redirect_uri.empty? && redirect_uri.length <= 2048
63
+
64
+ self.class.pkce_challenge(code_verifier)
65
+ body = { "grant_type" => "authorization_code", "client_id" => @client.client_id, "code" => code,
66
+ "code_verifier" => code_verifier, "redirect_uri" => redirect_uri }
67
+ Models::SsoToken.from_json(@client.request("exchangeSsoCode", [], nil, body))
68
+ rescue Error => e
69
+ raise RestartSignInError, e if %w[invalid_grant flow_interrupted_retry pkce_verification_failed].include?(e.code) && !e.is_a?(RestartSignInError)
70
+
71
+ raise
72
+ end
73
+
74
+ TOKEN_TYPES = %w[urn:ietf:params:oauth:token-type:jwt urn:ietf:params:oauth:token-type:id_token].freeze
75
+
76
+ # A trusted-token profile with the OhAuthCat access token issued for it.
77
+ TrustedTokenAccess = Struct.new(:profile, :access_token, :token_type, :expires_in, keyword_init: true)
78
+
79
+ # Exchanges a trusted third-party token for its profile and an OhAuthCat access token whose
80
+ # audience is client_id (the configured publishable key by default). The token's sub is the
81
+ # profile ID (tprof_...), not an end-user ID; exchanged identities are not billable.
82
+ def exchange_trusted_token_for_access(subject_token:, subject_token_type: TOKEN_TYPES.first, client_id: nil)
83
+ invalid = Error.client("request_invalid")
84
+ raise invalid unless @client.api_key.start_with?("sk_")
85
+ raise invalid unless subject_token.is_a?(String) && !subject_token.strip.empty? && subject_token.length <= 16_384
86
+ raise invalid unless TOKEN_TYPES.include?(subject_token_type)
87
+
88
+ audience = client_id.nil? ? @client.client_id : PasskeyHelpers.bounded(client_id, 256)
89
+ raise invalid if audience.nil?
90
+
91
+ body = { "grant_type" => "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token" => subject_token,
92
+ "subject_token_type" => subject_token_type, "client_id" => audience }
93
+ profile = Models::TrustedTokenProfile.from_json(@client.request("exchangeTrustedToken", [], nil, body))
94
+ raise Error.client("access_token_missing") if profile.access_token.nil? || profile.expires_in.nil?
95
+
96
+ TrustedTokenAccess.new(profile: profile, access_token: profile.access_token,
97
+ token_type: profile.token_type || "Bearer", expires_in: profile.expires_in)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OhAuthCat
4
+ # Edge failover to the standby hosts (api./auth.oauthcat.com). After `threshold` consecutive
5
+ # edge failures (timeouts, transport errors, HTTP 520-539) requests go to the standby origins;
6
+ # the primary is re-probed at most every 60 s and used again once healthy. A failure on the
7
+ # standby path switches back and blocks failover for the cooldown.
8
+ class Standby
9
+ DEFAULT_API = "https://api.ohauthcat.com"
10
+ DEFAULT_AUTH = "https://auth.ohauthcat.com"
11
+ STANDBY_API = "https://api.oauthcat.com"
12
+ STANDBY_AUTH = "https://auth.oauthcat.com"
13
+ PROBE_INTERVAL = 60.0
14
+
15
+ Snapshot = Struct.new(:origin, :on_standby, :revision, :retained, keyword_init: true)
16
+
17
+ attr_reader :api, :auth, :fallback_api, :fallback_auth, :enabled
18
+
19
+ def initialize(api:, auth:, enabled:, fallback_api:, fallback_auth:, threshold:, cooldown:, clock:)
20
+ @api = api
21
+ @auth = auth
22
+ @fallback_api = self.class.trusted_origin(fallback_api || STANDBY_API)
23
+ @fallback_auth = self.class.trusted_origin(fallback_auth || STANDBY_AUTH)
24
+ custom = api != DEFAULT_API || auth != DEFAULT_AUTH
25
+ explicit = !fallback_api.nil? && !fallback_auth.nil?
26
+ raise ArgumentError, "invalid standby" unless [true, false, nil].include?(enabled)
27
+ raise ArgumentError, "custom primary origins require both standby origins" if enabled == true && custom && !explicit
28
+
29
+ @enabled = enabled != false && (!custom || explicit)
30
+ raise ArgumentError, "standby_threshold must be 1..100" unless threshold.is_a?(Integer) && threshold.between?(1, 100)
31
+ raise ArgumentError, "standby_cooldown must be 0.001..3600 s" unless cooldown.is_a?(Numeric) && cooldown >= 0.001 && cooldown <= 3600
32
+ if @enabled && (api == @fallback_api || auth == @fallback_auth)
33
+ raise ArgumentError, "standby origins must differ from primary origins"
34
+ end
35
+
36
+ @threshold = threshold
37
+ @cooldown = cooldown
38
+ @clock = clock
39
+ @lock = Mutex.new
40
+ @active = false
41
+ @failures = 0
42
+ @blocked_until = @probe_after = clock.call
43
+ @probing = false
44
+ @revision = 0
45
+ @primary_failure = nil
46
+ end
47
+
48
+ def self.trusted_origin(value)
49
+ uri = URI.parse(value.to_s)
50
+ local = uri.scheme == "http" && %w[localhost 127.0.0.1 ::1 [::1]].include?(uri.host)
51
+ unless (uri.scheme == "https" || local) && uri.host && !uri.host.empty? && uri.userinfo.nil? && uri.query.nil? &&
52
+ uri.fragment.nil? && ["", "/"].include?(uri.path.to_s) && uri.port != 0
53
+ raise ArgumentError, "invalid standby origin"
54
+ end
55
+
56
+ value.to_s.chomp("/")
57
+ rescue URI::InvalidURIError
58
+ raise ArgumentError, "invalid standby origin"
59
+ end
60
+
61
+ # Transport failures and Cloudflare 52x/53x, never API answers such as 429 or 503.
62
+ def self.edge_error?(error)
63
+ error.status.between?(520, 539) ||
64
+ (%w[transport_error request_timeout].include?(error.code) && ![429, 503].include?(error.status))
65
+ end
66
+
67
+ def snapshot(auth_host)
68
+ @lock.synchronize do
69
+ origin = if @active
70
+ auth_host ? @fallback_auth : @fallback_api
71
+ else
72
+ auth_host ? @auth : @api
73
+ end
74
+ Snapshot.new(origin: origin, on_standby: @active, revision: @revision, retained: @primary_failure)
75
+ end
76
+ end
77
+
78
+ def success(revision)
79
+ @lock.synchronize { @failures = 0 if revision == @revision && !@active }
80
+ end
81
+
82
+ def edge(revision, error)
83
+ @lock.synchronize do
84
+ return false if revision != @revision || !@enabled || @active
85
+
86
+ @failures += 1
87
+ return false if @failures < @threshold || @clock.call < @blocked_until
88
+
89
+ @active = true
90
+ @primary_failure = error
91
+ @probe_after = @clock.call + PROBE_INTERVAL
92
+ @revision += 1
93
+ true
94
+ end
95
+ end
96
+
97
+ def unavailable(revision)
98
+ @lock.synchronize do
99
+ next unless revision == @revision
100
+
101
+ @active = false
102
+ @failures = 0
103
+ @blocked_until = @clock.call + @cooldown
104
+ @revision += 1
105
+ end
106
+ end
107
+
108
+ def begin_probe
109
+ @lock.synchronize do
110
+ return nil if !@active || @probing || @clock.call < @probe_after
111
+
112
+ @probing = true
113
+ @probe_after = @clock.call + PROBE_INTERVAL
114
+ @revision
115
+ end
116
+ end
117
+
118
+ def finish_probe(revision, healthy)
119
+ @lock.synchronize do
120
+ @probing = false
121
+ if healthy && revision == @revision
122
+ @active = false
123
+ @failures = 0
124
+ @revision += 1
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OhAuthCat
4
+ VERSION = "0.0.2"
5
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OhAuthCat
4
+ # Webhook signature verification (OhAuthCat-Signature: t=<unix>,v1=<hex>). Verify the raw
5
+ # body bytes before parsing; comparison is constant-time.
6
+ module Webhooks
7
+ SIGNATURE_HEADER = "OhAuthCat-Signature"
8
+ DEFAULT_TOLERANCE = 300
9
+ MAX_SAFE = (2**53) - 1
10
+
11
+ # Verification failed; #code never contains secrets, headers or the body.
12
+ class VerificationError < StandardError
13
+ attr_reader :code
14
+
15
+ def initialize(code)
16
+ super("Webhook verification failed: #{code}")
17
+ @code = code
18
+ end
19
+ end
20
+
21
+ module_function
22
+
23
+ def parse_header(header)
24
+ raise VerificationError, "header_missing" if !header.is_a?(String) || header.strip.empty?
25
+
26
+ timestamp = nil
27
+ signatures = []
28
+ header.split(",", -1).each do |part|
29
+ name, sep, value = part.strip.partition("=")
30
+ raise VerificationError, "header_invalid" if sep.empty?
31
+
32
+ case name
33
+ when "t"
34
+ raise VerificationError, "header_invalid" if timestamp || value.length > 20 || !value.match?(/\A[0-9]+\z/)
35
+
36
+ timestamp = value.to_i
37
+ raise VerificationError, "header_invalid" unless timestamp.positive? && timestamp <= MAX_SAFE
38
+ when "v1"
39
+ signature = value.downcase
40
+ signatures << signature if signature.bytesize == 64
41
+ end
42
+ end
43
+ raise VerificationError, "header_invalid" if timestamp.nil? || signatures.empty?
44
+
45
+ [timestamp, signatures]
46
+ end
47
+
48
+ # Hex HMAC-SHA256 over "<timestamp>.<body>", keyed by the whole UTF-8 secret.
49
+ def signature_digest(raw_body, timestamp, secret)
50
+ raise VerificationError, "body_invalid" unless raw_body.is_a?(String)
51
+ raise VerificationError, "secret_missing" unless secret.is_a?(String)
52
+
53
+ OpenSSL::HMAC.hexdigest("SHA256", secret.b, "#{timestamp}.".b + raw_body.b)
54
+ end
55
+
56
+ def verify_signature(raw_body, header, secrets, tolerance: DEFAULT_TOLERANCE, now: nil)
57
+ raise VerificationError, "body_invalid" unless raw_body.is_a?(String)
58
+ raise VerificationError, "options_invalid" unless tolerance.is_a?(Numeric) && tolerance.positive?
59
+
60
+ timestamp, signatures = parse_header(header)
61
+ current = now || Time.now.to_i
62
+ raise VerificationError, "timestamp_outside" if (current - timestamp).abs > tolerance
63
+
64
+ held = secrets.is_a?(String) ? [secrets] : secrets
65
+ raise VerificationError, "secret_missing" unless held.is_a?(Array) && held.all?(String)
66
+
67
+ matched = false
68
+ nonempty = false
69
+ held.each do |secret|
70
+ next if secret.empty?
71
+
72
+ nonempty = true
73
+ expected = signature_digest(raw_body, timestamp, secret)
74
+ signatures.each do |signature|
75
+ matched = OpenSSL.fixed_length_secure_compare(expected, signature) | matched
76
+ end
77
+ end
78
+ raise VerificationError, "secret_missing" unless nonempty
79
+ raise VerificationError, "signature_invalid" unless matched
80
+
81
+ nil
82
+ end
83
+
84
+ # Verifies the signature, then returns the decoded event Hash.
85
+ def verify(raw_body, header, secrets, tolerance: DEFAULT_TOLERANCE, now: nil)
86
+ verify_signature(raw_body, header, secrets, tolerance: tolerance, now: now)
87
+ event = begin
88
+ JSON.parse(raw_body.dup.force_encoding(Encoding::UTF_8))
89
+ rescue JSON::ParserError, EncodingError
90
+ raise VerificationError, "event_invalid"
91
+ end
92
+ raise VerificationError, "event_invalid" unless event.is_a?(Hash) && event["id"].is_a?(String) && event["type"].is_a?(String)
93
+
94
+ event
95
+ end
96
+ end
97
+ end
data/lib/ohauthcat.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "uri"
8
+
9
+ require_relative "ohauthcat/version"
10
+ require_relative "ohauthcat/errors"
11
+ require_relative "ohauthcat/route"
12
+ require_relative "ohauthcat/model"
13
+ require_relative "ohauthcat/models"
14
+ require_relative "ohauthcat/access_policy"
15
+ require_relative "ohauthcat/standby"
16
+ require_relative "ohauthcat/generated"
17
+ require_relative "ohauthcat/webhooks"
18
+ require_relative "ohauthcat/sso_helpers"
19
+ require_relative "ohauthcat/passkey_helpers"
20
+ require_relative "ohauthcat/client"
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ohauthcat
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - OhAuthCat
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Server-side SDK for OhAuthCat: enterprise SSO (SAML 2.0 and OIDC), SCIM
13
+ directory sync, webhooks and the management API.'
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - LICENSE
19
+ - README.md
20
+ - THIRD_PARTY_LICENSES
21
+ - lib/ohauthcat.rb
22
+ - lib/ohauthcat/access_policy.rb
23
+ - lib/ohauthcat/client.rb
24
+ - lib/ohauthcat/errors.rb
25
+ - lib/ohauthcat/generated.rb
26
+ - lib/ohauthcat/model.rb
27
+ - lib/ohauthcat/models.rb
28
+ - lib/ohauthcat/passkey_helpers.rb
29
+ - lib/ohauthcat/route.rb
30
+ - lib/ohauthcat/sso_helpers.rb
31
+ - lib/ohauthcat/standby.rb
32
+ - lib/ohauthcat/version.rb
33
+ - lib/ohauthcat/webhooks.rb
34
+ homepage: https://ohauthcat.com
35
+ licenses:
36
+ - Nonstandard
37
+ metadata:
38
+ homepage_uri: https://ohauthcat.com
39
+ documentation_uri: https://docs.ohauthcat.com
40
+ rubygems_mfa_required: 'true'
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.2'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 3.6.9
56
+ specification_version: 4
57
+ summary: 'OhAuthCat server SDK: enterprise SSO and SCIM directory sync'
58
+ test_files: []