| 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 |