| 1 | --- |
| 2 | name: smbcloud-auth |
| 3 | description: Use when working on authentication in the sigit-si Rails app — sign in, sign up, profile (me), sign out, or account removal. sigit-si is a platform client ("sigit") that consumes smbCloud Auth as an auth-as-a-service layer through the smbcloud-auth Ruby gem. Covers the SmbcloudAuthService wrapper, the local User upsert, env/credential config, the native-extension build caveat, and the planned /api/v1 JSON surface for the siGit Code & Deploy desktop app. |
| 4 | --- |
| 5 | |
| 6 | # smbCloud Auth in sigit-si |
| 7 | |
| 8 | > **Product map:** siGit Code (local agent) · siGit Code Cloud (hosted chat + Cloud Sessions) · siGit Code Cloud Agent (autonomous task → PR; planning). `sigit.si` is Git hosting; `code.sigit.si` is the home of siGit Code. Full taxonomy: [product-overview](../../../docs/product/product-overview.md). |
| 9 | |
| 10 | sigit-si does **not** own user identity. It is a **platform client** (client id |
| 11 | `sigit`) that authenticates against the shared **smbCloud Auth** service. Treat |
| 12 | smbCloud Auth as the source of truth for credentials; sigit-si keeps only a thin |
| 13 | local mirror of the user. |
| 14 | |
| 15 | For the full auth model (platform clients vs tenant auth apps, the Rust/WASM |
| 16 | SDKs, the web console), see the authoritative skill in |
| 17 | `smbcloud-cli/.agents/skills/smbcloud-auth/SKILL.md`. This file is the |
| 18 | **sigit-si-specific** view. |
| 19 | |
| 20 | ## Where things live |
| 21 | |
| 22 | - `app/services/smbcloud_auth_service.rb` — the only place that talks to the gem. |
| 23 | Do not call `SmbCloud::Auth` directly from controllers; go through this service. |
| 24 | - `app/controllers/sessions_controller.rb` — HTML sign in / sign out (`/auth`). |
| 25 | - `app/controllers/registrations_controller.rb` — HTML sign up (`/auth/signup`). |
| 26 | - `app/controllers/oauth/provider_controller.rb` — shared "Continue with |
| 27 | <provider>" flow (`/auth/<provider>` → smbCloud authorize; |
| 28 | `/auth/<provider>/callback` consumes the returned `access_token`). smbCloud |
| 29 | brokers the provider OAuth flow; this app only starts it and reuses the same |
| 30 | `me` → upsert → session path as `sessions#create`. Thin subclasses: |
| 31 | `oauth/github_controller.rb`, `oauth/google_controller.rb`. The SPA |
| 32 | (code.sigit.si) equivalents live under `api/v1/oauth/` (`brokered_controller.rb` |
| 33 | base + per-provider subclasses) with a redirect_uri allowlist. |
| 34 | Adding a provider = two ~15-line subclasses + `SmbcloudAuthService.<p>_authorize_url` |
| 35 | + routes + a button partial — provided smbcloud-api brokers it and the |
| 36 | AuthApp has the provider's OAuth client configured (e.g. |
| 37 | `google_oauth_client_id/secret` on the auth app; without it the broker |
| 38 | returns "Google sign-in is not configured for this app"). |
| 39 | - `app/models/user.rb` — local mirror, upserted via |
| 40 | `User.find_or_create_from_smbcloud(profile.merge(access_token:))`. |
| 41 | - `Gemfile` — `gem "smbcloud-auth", "~> 0.4.5"` (native Rust/Magnus extension). |
| 42 | |
| 43 | ## SmbcloudAuthService surface |
| 44 | |
| 45 | Class methods, all of which translate gem errors into local error types: |
| 46 | |
| 47 | - `login(email:, password:)` → `access_token` (String) |
| 48 | - `me(access_token:)` → profile hash `{ id:, email:, created_at:, updated_at: }` |
| 49 | - `signup(email:, password:)` |
| 50 | - `logout(access_token:)` |
| 51 | - `remove(access_token:)` — deletes the smbCloud account |
| 52 | - `client` — memoized `SmbCloud::Auth` client built from env config |
| 53 | |
| 54 | ### Error contract |
| 55 | |
| 56 | - `SmbcloudAuthService::AuthenticationError` — base; carries optional `error_code` |
| 57 | - `SmbcloudAuthService::AccountNotFoundError` — no account for that email |
| 58 | - `SmbcloudAuthService::AccountIncompleteError` — account exists but not verified; |
| 59 | surface the "check your email" message, do not treat as bad credentials |
| 60 | - Underlying gem failures arrive as `SmbCloud::Auth::Error` (with `error_code`) |
| 61 | and must be caught inside the service, never leaked to controllers. |
| 62 | |
| 63 | Controllers map these to flash + HTTP status (`:unprocessable_entity` for auth |
| 64 | failures, `:internal_server_error` for config/unexpected). Preserve that mapping. |
| 65 | |
| 66 | ## Local user mirror & identity reconciliation |
| 67 | |
| 68 | `app/models/user.rb` mirrors the smbCloud account. The upsert keys on |
| 69 | `smbcloud_id` (smbCloud's numeric `auth_user.id`) but **reconciles by email** — |
| 70 | and that reconciliation is load-bearing, not a nicety: |
| 71 | |
| 72 | > **smbCloud's `auth_user.id` is per-AuthApp, not a stable global identity.** |
| 73 | > The same email is a *different* `auth_user` (different id) in every AuthApp, |
| 74 | > and the id also changes if the upstream account is deleted/recreated. So the |
| 75 | > moment sigit-si is repointed at a different AuthApp — e.g. moving the app to a |
| 76 | > different smbCloud **account** — every existing local `User.smbcloud_id` goes |
| 77 | > stale: it points at an `auth_user` that no longer backs that email. |
| 78 | |
| 79 | Because `User.email` is locally unique, a `smbcloud_id`-only upsert then breaks: |
| 80 | a login returns the email's *current* `auth_user.id`, no local row matches it, |
| 81 | and inserting a new row collides on the unique email → |
| 82 | `ActiveRecord::RecordInvalid`, which surfaces as a generic **"An unexpected error |
| 83 | occurred"** on sign-in (this is exactly how the GitHub callback failed at |
| 84 | launch — the GitHub login resolved to the email's *current* AuthApp `auth_user`, |
| 85 | which differed from the id stored when the app pointed at an earlier |
| 86 | AuthApp/account). |
| 87 | |
| 88 | `find_or_create_from_smbcloud` therefore does: look up by `smbcloud_id`; if that |
| 89 | misses **and** the authenticated email already has a local user, adopt that row |
| 90 | and move it onto the new `smbcloud_id`. Treat **email as the durable key** and |
| 91 | `smbcloud_id` as a pointer that can change. Never reintroduce a |
| 92 | `smbcloud_id`-only upsert. (Specs: `spec/models/user_smbcloud_spec.rb`.) |
| 93 | |
| 94 | ## Credentials & environment |
| 95 | |
| 96 | Required env vars (raise `KeyError` if missing — handle as a config error, not an |
| 97 | auth failure): |
| 98 | |
| 99 | - `SIGITSI_SMBCLOUD_APP_ID` |
| 100 | - `SIGITSI_SMBCLOUD_APP_SECRET` |
| 101 | |
| 102 | The smbCloud environment (dev vs production) is selected inside the service. |
| 103 | Keep dev and production app credentials separate; never hardcode either. |
| 104 | |
| 105 | Security note: `sigit-si` is a **server-side confidential client**, so holding |
| 106 | `app_secret` here is fine. This is different from the desktop/browser clients — |
| 107 | see the public-client rule in the smbcloud-cli skill before reusing this pattern |
| 108 | in `sigit-app`. |
| 109 | |
| 110 | ## Native extension build caveat |
| 111 | |
| 112 | `smbcloud-auth` compiles a Rust extension via `magnus`/`rb_sys`. The pinned |
| 113 | `magnus` version does **not** build against **Ruby 4.0.x**: |
| 114 | |
| 115 | ``` |
| 116 | error[E0609]: no field `typed_flag` on type &rb_sys::RTypedData |
| 117 | error: could not compile `magnus` |
| 118 | ``` |
| 119 | |
| 120 | This affects every published version of the gem, not a specific one. If a |
| 121 | `bundle install` fails to build the native extension on Ruby 4.0, the fix is in |
| 122 | the **gem** (bump `magnus`/`rb_sys` in its `ext/auth/Cargo.toml` and republish) |
| 123 | or build under Ruby 3.3.x — not in this repo's Gemfile. |
| 124 | |
| 125 | ### macOS 26/27: dlopen rejects the built bundle (empty LC_ID_DYLIB) |
| 126 | |
| 127 | On macOS 26/27, a source-gem install of `smbcloud-auth` builds fine but fails |
| 128 | at runtime with: |
| 129 | |
| 130 | ``` |
| 131 | LoadError (dlopen(.../smbcloud-auth-X.Y.Z/lib/auth/auth.bundle): |
| 132 | load command #4 string extends beyond end of load command) |
| 133 | ``` |
| 134 | |
| 135 | Every request that touches `SmbcloudAuthService` then 500s (it does |
| 136 | `require "auth"` at file-eval). Root cause: rb-sys's generated Makefile |
| 137 | (`fixup_libnames` in `rb_sys/mkmf.rb`) ends the build with |
| 138 | `install_name_tool -id "" $(DLLIB)`, leaving an **empty `LC_ID_DYLIB` install |
| 139 | name**, which the stricter dyld on macOS 26/27 rejects. `gem pristine` does |
| 140 | NOT help — it rebuilds through the same broken step. |
| 141 | |
| 142 | - **Durable fix** lives in the gem source (`smbcloud-cli` repo, merged to |
| 143 | `development` 2026-07-02): `sdk/gems/auth/ext/auth/extconf.rb` post-processes |
| 144 | the generated Makefile to stamp `-id "@rpath/auth.bundle"` (covers |
| 145 | `gem install`), and the gem's `Rakefile` does the same for `rake native gem` |
| 146 | builds. Ships in the next gem release after 0.4.5. |
| 147 | - **Local band-aid** if a broken bundle is already installed: |
| 148 | |
| 149 | ```sh |
| 150 | install_name_tool -id "@rpath/auth.bundle" <path>/auth.bundle |
| 151 | codesign -f -s - <path>/auth.bundle |
| 152 | ``` |
| 153 | |
| 154 | - Diagnose with `otool -l auth.bundle | grep -A3 LC_ID_DYLIB` — an empty |
| 155 | `name (offset 24)` is the broken state. |
| 156 | |
| 157 | ## Planned: /api/v1 JSON auth for the desktop app |
| 158 | |
| 159 | The "siGit Code & Deploy" Tauri app (`sigit-app`) needs a JSON, token-based auth |
| 160 | surface — the current controllers are HTML + session-cookie only. When adding |
| 161 | `sigit.si/api/v1`: |
| 162 | |
| 163 | - add an `Api::V1` controller namespace; reuse `SmbcloudAuthService`, do not |
| 164 | re-implement gem calls |
| 165 | - authenticate requests with the smbCloud `access_token` (bearer), not the Rails |
| 166 | session cookie |
| 167 | - return JSON shapes that match the desktop client's `account/command_*` set: |
| 168 | login, signup, me, logout, remove_account, resend confirmation, reset password, |
| 169 | check email/oauth |
| 170 | - keep the HTML controllers working; the API is additive (migrating the HTML |
| 171 | flows onto the API is optional, not required) |
| 172 | - keep `sigit-app` account features at parity with the sigit-si web account page |
| 173 | |
| 174 | ## Validation |
| 175 | |
| 176 | - `bundle exec rails routes | grep -E 'auth|api/v1'` |
| 177 | - `ruby -c` on touched controllers/services |
| 178 | - `bundle exec rails db:migrate` if the local `User` schema changed |
| 179 | - exercise sign in / sign out / sign up against the dev smbCloud environment |
| 180 | |
| 181 | ## Common mistakes |
| 182 | |
| 183 | - Calling `SmbCloud::Auth` directly from a controller instead of via the service |
| 184 | - Keying the local `User` upsert **only** on `smbcloud_id` — it is per-AuthApp |
| 185 | and changes when the app is repointed at a different AuthApp/account (or an |
| 186 | upstream account is recreated); reconcile by email |
| 187 | - Treating `AccountIncompleteError` as bad credentials instead of "verify email" |
| 188 | - Leaking `SmbCloud::Auth::Error` (or its `error_code`) to the view layer |
| 189 | - Using the Rails session cookie to authenticate desktop-app API requests |
| 190 | - Assuming a gem version bump caused a native build failure that is really the |
| 191 | Ruby 4.0 / magnus incompatibility |
| 192 | - Reusing the server-side `app_secret` pattern in a public client (desktop/browser) |