main
rb 270 lines 9.87 KB
Raw
1 # frozen_string_literal: true
2
3 # Thin wrapper around the smbcloud-auth native gem.
4 #
5 # The gem (`smbcloud-auth`) is a Magnus/Rust extension that provides
6 # direct credential-based auth against the smbCloud Auth API. It exposes:
7 #
8 # client.login(email:, password:)
9 # → { status: "ready"|"not_found"|"incomplete",
10 # access_token: String|nil,
11 # error_code: Integer|nil,
12 # message: String|nil }
13 #
14 # client.me(access_token:)
15 # → { id: Integer, email: String, created_at: String, updated_at: String }
16 #
17 # client.signup(email:, password:)
18 # client.logout(access_token:)
19 # client.remove(access_token:)
20 #
21 # Required env vars:
22 # SIGITSI_SMBCLOUD_APP_ID – your smbCloud app ID
23 # SIGITSI_SMBCLOUD_APP_SECRET – your smbCloud app secret
24 # SIGITSI_SMBCLOUD_ENVIRONMENT – "production" (default) or "dev"
25 #
26 require "auth"
27 require "net/http"
28 require "json"
29
30 class SmbcloudAuthService
31 class AuthenticationError < StandardError
32 attr_reader :error_code
33
34 def initialize(message, error_code: nil)
35 super(message)
36 @error_code = error_code
37 end
38 end
39
40 class AccountNotFoundError < AuthenticationError; end
41 class AccountIncompleteError < AuthenticationError; end
42
43 # ---------------------------------------------------------------------------
44 # Client factory
45 # ---------------------------------------------------------------------------
46
47 def self.client
48 SmbCloud::Auth::Client.new(
49 environment: smbcloud_environment,
50 app_id: smbcloud_app_id,
51 app_secret: smbcloud_app_secret
52 )
53 end
54
55 # ---------------------------------------------------------------------------
56 # Public API
57 # ---------------------------------------------------------------------------
58
59 # Authenticates an email/password pair.
60 #
61 # Returns the access_token String on success.
62 # Raises AuthenticationError (or a subclass) on failure.
63 def self.login(email:, password:)
64 result = client.login(email: email, password: password)
65
66 case result[:status]
67 when "ready"
68 result[:access_token]
69 when "not_found"
70 raise AccountNotFoundError.new("No account found for that email address.")
71 when "incomplete"
72 raise AccountIncompleteError.new(
73 result[:message] || "Account setup is incomplete. Please check your email.",
74 error_code: result[:error_code]
75 )
76 else
77 raise AuthenticationError.new("Unexpected authentication response: #{result[:status]}")
78 end
79 rescue SmbCloud::Auth::Error => e
80 raise AuthenticationError.new(e.message, error_code: e.error_code)
81 end
82
83 # Builds the smbCloud "Sign in with GitHub" authorize URL. The browser is
84 # redirected here; smbCloud brokers the GitHub OAuth dance and bounces back to
85 # `redirect_uri` with an `access_token` (or an `error`) query param.
86 def self.github_authorize_url(redirect_uri:)
87 oauth_authorize_url(provider: "github", redirect_uri: redirect_uri)
88 end
89
90 # Same brokered flow for "Sign in with Google" (smbCloud runs the whole
91 # Google OAuth2/OIDC exchange server-side, like GitHub).
92 def self.google_authorize_url(redirect_uri:)
93 oauth_authorize_url(provider: "google", redirect_uri: redirect_uri)
94 end
95
96 # Mirrors the platform's existing client convention of passing the app's
97 # client_id/client_secret as query params (see #post_client).
98 def self.oauth_authorize_url(provider:, redirect_uri:)
99 uri = URI.parse("#{smbcloud_base_url}/v1/client/oauth/#{provider}/authorize")
100 uri.query = URI.encode_www_form(
101 client_id: smbcloud_app_id,
102 client_secret: smbcloud_app_secret,
103 redirect_uri: redirect_uri
104 )
105 uri.to_s
106 end
107
108 # Fetches the current user's profile from the smbCloud Auth API.
109 #
110 # Returns a symbolized hash:
111 # { id: Integer, email: String, created_at: String, updated_at: String }
112 def self.me(access_token:)
113 client.me(access_token: access_token)
114 rescue SmbCloud::Auth::Error => e
115 raise AuthenticationError.new(e.message, error_code: e.error_code)
116 end
117
118 # Registers a new account.
119 #
120 # Returns the signup result hash on success.
121 # Raises AuthenticationError on failure.
122 def self.signup(email:, password:)
123 client.signup(email: email, password: password)
124 rescue SmbCloud::Auth::Error => e
125 raise AuthenticationError.new(e.message, error_code: e.error_code)
126 end
127
128 # Invalidates the given access_token on the smbCloud Auth server.
129 def self.logout(access_token:)
130 client.logout(access_token: access_token)
131 rescue SmbCloud::Auth::Error => e
132 Rails.logger.warn("smbCloud logout error (non-fatal): #{e.message}")
133 false
134 end
135
136 # Permanently removes the account from smbCloud Auth.
137 def self.remove(access_token:)
138 client.remove(access_token: access_token)
139 rescue SmbCloud::Auth::Error => e
140 raise AuthenticationError.new(e.message, error_code: e.error_code)
141 end
142
143 # Asks smbCloud to resend the confirmation email for an unconfirmed account.
144 #
145 # The gem does not wrap this endpoint, so we call it directly. The endpoint
146 # intentionally always returns 200 with a neutral message (no account
147 # enumeration), so this returns the parsed body and never reveals whether the
148 # email belongs to a real or still-unconfirmed account.
149 def self.resend_confirmation(email:)
150 post_client("v1/client/users/resend_confirmation", user: { email: email })
151 end
152
153 # Asks smbCloud to email password-reset instructions for an account.
154 #
155 # Wrapped natively by the gem (>= 0.4.4). Like resend_confirmation, the
156 # endpoint always responds the same way regardless of whether the email
157 # exists (no enumeration), and calling it again re-issues the token, so it
158 # also serves the "resend reset instructions" flow. Returns the gem's
159 # { code:, message: } hash.
160 def self.reset_password(email:)
161 client.reset_password(email: email)
162 rescue SmbCloud::Auth::Error => e
163 raise AuthenticationError.new(e.message, error_code: e.error_code)
164 end
165
166 # Completes a password reset: submits the token (from the email link) and the
167 # new password to smbCloud. Raises AuthenticationError (with the API's message)
168 # if the token is invalid/expired or the password is rejected.
169 def self.complete_password_reset(reset_password_token:, password:, password_confirmation:)
170 post_client(
171 "v1/client/users/reset_password/complete",
172 reset_password_token: reset_password_token,
173 password: password,
174 password_confirmation: password_confirmation
175 )
176 end
177
178 # ---------------------------------------------------------------------------
179 # Private helpers
180 # ---------------------------------------------------------------------------
181
182 # Resolves the configured environment to :dev or :production.
183 # In development the dev panel can override via Rails.cache.
184 def self.resolved_environment_name
185 raw = if Rails.env.development?
186 Rails.cache.read("dev:smbcloud_environment") ||
187 ENV.fetch("SIGITSI_SMBCLOUD_ENVIRONMENT", "dev")
188 else
189 ENV.fetch("SIGITSI_SMBCLOUD_ENVIRONMENT", "production")
190 end.strip.downcase
191
192 case raw
193 when "dev", "development" then :dev
194 when "production" then :production
195 else
196 Rails.logger.warn("Unknown SIGITSI_SMBCLOUD_ENVIRONMENT '#{raw}', defaulting to production.")
197 :production
198 end
199 end
200
201 def self.smbcloud_environment
202 case resolved_environment_name
203 when :dev then SmbCloud::Auth::Environment::DEV
204 else SmbCloud::Auth::Environment::PRODUCTION
205 end
206 end
207
208 # Base URL for direct HTTP calls to tenant endpoints the gem doesn't wrap.
209 # Mirrors the gem's Environment hosts (dev: localhost:8088, prod: api.smbcloud.xyz).
210 def self.smbcloud_base_url
211 case resolved_environment_name
212 when :dev then "http://localhost:8088"
213 else "https://api.smbcloud.xyz"
214 end
215 end
216
217 # POSTs a JSON payload to a tenant client endpoint, authenticating with the
218 # app's client_id/client_secret query params (the same shape the gem uses).
219 # Returns the parsed JSON body, or {} when the body is empty.
220 def self.post_client(path, payload)
221 uri = URI.parse("#{smbcloud_base_url}/#{path}")
222 uri.query = URI.encode_www_form(
223 client_id: smbcloud_app_id,
224 client_secret: smbcloud_app_secret
225 )
226
227 request = Net::HTTP::Post.new(uri)
228 request["Content-Type"] = "application/json"
229 request["User-Agent"] = smbcloud_app_id
230 request.body = payload.to_json
231
232 response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
233 http.request(request)
234 end
235
236 unless response.is_a?(Net::HTTPSuccess)
237 # Surface the API's own message (e.g. "Reset password token is invalid")
238 # when present, so callers can show something useful instead of a bare
239 # status code.
240 body = response.body.to_s.empty? ? {} : (JSON.parse(response.body) rescue {})
241 message = body["message"].presence || "smbCloud request failed (HTTP #{response.code})."
242 raise AuthenticationError.new(message, error_code: body["error_code"])
243 end
244
245 response.body.to_s.empty? ? {} : JSON.parse(response.body)
246 rescue AuthenticationError, KeyError
247 raise
248 rescue StandardError => e
249 # Log the detail server-side; return a clean message so internal infra
250 # (hosts, ports) never leaks to API clients or the flash.
251 Rails.logger.warn("smbCloud request to #{path} failed: #{e.class}: #{e.message}")
252 raise AuthenticationError.new("Network error contacting the authentication service.")
253 end
254
255 def self.smbcloud_app_id
256 ENV.fetch("SIGITSI_SMBCLOUD_APP_ID") do
257 raise KeyError, "Missing required env var: SIGITSI_SMBCLOUD_APP_ID"
258 end
259 end
260
261 def self.smbcloud_app_secret
262 ENV.fetch("SIGITSI_SMBCLOUD_APP_SECRET") do
263 raise KeyError, "Missing required env var: SIGITSI_SMBCLOUD_APP_SECRET"
264 end
265 end
266
267 private_class_method :client, :resolved_environment_name, :smbcloud_environment,
268 :smbcloud_base_url, :post_client, :smbcloud_app_id, :smbcloud_app_secret,
269 :oauth_authorize_url
270 end