feat(auth): add brokered Google sign-in (web + code.sigit.si SPA)

Mirror the GitHub flow for Google, which smbCloud Auth already brokers server-side (v1/client/oauth/google/authorize). Refactor both layers so providers share one implementation: - Oauth::ProviderController — shared start/callback/session flow; GitHub and Google are thin subclasses - Api::V1::Oauth::BrokeredController — shared SPA redirect with the per-provider redirect_uri allowlist - SmbcloudAuthService.google_authorize_url via a common URL builder - Sign-in and sign-up render an oauth_buttons partial with both providers Requires google_oauth_client_id/secret on the smbCloud AuthApp; until configured the broker returns "Google sign-in is not configured". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Seto Elkahfi committed Jul 3, 2026 at 00:04 UTC df585756b653e8206b23994b1820231b2ae00a4f
16 files changed +401 -133
.agents/skills/local-environment/SKILL.md
+64 -1
@@ -1,6 +1,69 @@
1 ---
2 name: local-environment
3 -description: A skill for working with the local environment for sigit.si
3 +description: A skill for working with the local environment for sigit.si — running the dev stack (bin/dev + local smbcloud-api), env vars, test accounts, and the gotchas that break local sign-in (missing .env, unbuilt tailwind.css, macOS 26/27 native-gem dlopen).
4 ---
5
6 > **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).
7 +
8 +## Running the full local stack
9 +
10 +Two processes make local sign-in work end to end (verified 2026-07-02):
11 +
12 +1. **sigit-si** — `bin/dev` (Puma on **:3000** + Tailwind watcher). Do NOT use a
13 + bare `bin/rails server`: it neither builds `app/assets/builds/tailwind.css`
14 + (→ `Propshaft::MissingAssetError` on every page) nor is there any other
15 + process to build it. One-off alternative: `bin/rails tailwindcss:build`.
16 +2. **smbcloud-api** — the local auth backend at
17 + `~/Repositories/smbcloud/services/smbcloud-api`, `bin/rails server`
18 + (Puma on **:8088**, from `API_SMBCLOUD_XYZ_PORT`). Required whenever
19 + `SIGITSI_SMBCLOUD_ENVIRONMENT=dev`, because the smbcloud-auth gem's dev
20 + environment hard-points at `http://localhost:8088`. If it isn't running,
21 + sign-in fails with a network error.
22 +
23 +## Environment (.env)
24 +
25 +- `.env` at the repo root is gitignored and **required**:
26 + `SIGITSI_SMBCLOUD_APP_ID`, `SIGITSI_SMBCLOUD_APP_SECRET`,
27 + `SIGITSI_SMBCLOUD_ENVIRONMENT` (`dev` → localhost:8088, `production` →
28 + api.smbcloud.xyz). Missing vars surface as the flash
29 + **"Authentication service is not configured. Please contact support."**
30 + (controllers rescue the `KeyError` from `ENV.fetch`).
31 +- `bin/dev` (foreman) auto-loads `.env`; a bare `rails server`/`rails runner`
32 + does not (`set -a && . ./.env && set +a` first if needed).
33 +- **Git worktrees don't inherit `.env`** (gitignored). In a worktree, symlink
34 + it: `ln -s ~/Repositories/sigit-si/.env .env`. Same story for
35 + `app/assets/builds/` artifacts — rebuild them in the worktree.
36 +
37 +## Test accounts (local dev)
38 +
39 +The local smbcloud-api Postgres DB (`smbpndk_api_development`) has the sigit-si
40 +tenant registered as auth_app **"siGit Code & Deploy"**
41 +(`880e7dea-aacb-4ca4-a059-3a649a98f86a` = `SIGITSI_SMBCLOUD_APP_ID`; users were
42 +migrated here from the legacy "Rumi App Auth" app on 2026-07-02). Its
43 +`auth_users` (all confirmed) include `test@test.com`, `setoelkahfi@gmail.com`,
44 +`seto@smbcloud.xyz` — passwords are bcrypt-hashed, so use one you know, sign up
45 +a fresh local account, or reset via the smbcloud-api console. Inspect with:
46 +
47 +```sh
48 +psql -h localhost -d smbpndk_api_development \
49 + -c "select id, email, confirmed_at is not null from auth_users
50 + where auth_app_id = '880e7dea-aacb-4ca4-a059-3a649a98f86a';"
51 +```
52 +
53 +Note: the platform-level seed accounts in smbcloud-api
54 +(`db/seeds/demo_accounts.rb`, e.g. `demo@smbcloud.xyz`) are platform `users`,
55 +**not** sigit-si tenant users — they will not work on the sigit-si login form.
56 +The production demo account is `demo@sigit.si` (prod credential).
57 +
58 +## Known local-only failure modes
59 +
60 +- **`LoadError: ... auth.bundle ... load command #4 string extends beyond end
61 + of load command`** on macOS 26/27 — broken `LC_ID_DYLIB` in the
62 + smbcloud-auth native extension; fix and band-aid documented in
63 + [smbcloud-auth](../smbcloud-auth/SKILL.md) under "macOS 26/27".
64 +- **`The asset 'tailwind.css' was not found in the load path`** — CSS never
65 + built in this checkout/worktree; run `bin/dev` or `bin/rails tailwindcss:build`.
66 +- **"Authentication service is not configured."** — `.env` missing/not loaded
67 + (see above).
68 +- **Network error on sign-in with `SIGITSI_SMBCLOUD_ENVIRONMENT=dev`** — local
69 + smbcloud-api not running on :8088.
.agents/skills/smbcloud-auth/SKILL.md
+46 -5
@@ -23,13 +23,22 @@ SDKs, the web console), see the authoritative skill in
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/github_controller.rb` — "Continue with GitHub"
27 - (`/auth/github` → smbCloud authorize; `/auth/github/callback` consumes the
28 - returned `access_token`). smbCloud brokers the GitHub OAuth flow; this app only
29 - starts it and reuses the same `me` → upsert → session path as `sessions#create`.
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:))`.
32 -- `Gemfile` — `gem "smbcloud-auth", "~> 0.3.35"` (native Rust/Magnus extension).
41 +- `Gemfile` — `gem "smbcloud-auth", "~> 0.4.5"` (native Rust/Magnus extension).
42
43 ## SmbcloudAuthService surface
44
@@ -113,6 +122,38 @@ This affects every published version of the gem, not a specific one. If a
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
app/controllers/api/v1/oauth/brokered_controller.rb new
+78
@@ -0,0 +1,78 @@
1 +# frozen_string_literal: true
2 +
3 +module Api
4 + module V1
5 + module Oauth
6 + # Brokered social sign-in for browser SPA clients (code.sigit.si).
7 + #
8 + # The web app (Oauth::ProviderController) keeps the whole flow on sigit.si
9 + # and establishes a cookie session. A client-only SPA can't do that: it has
10 + # no server to hold the smbCloud app secret, and it needs a *token* back,
11 + # not a cookie. So this endpoint plays the server half of the dance:
12 + #
13 + # 1. #start (here) — the SPA points the browser at
14 + # GET /api/v1/auth/<provider>?redirect_uri=<spa>/auth/<provider>/callback.
15 + # We inject the app secret server-side and 302 to smbCloud's authorize
16 + # URL, carrying the SPA's callback as the redirect_uri.
17 + # 2. smbCloud brokers the provider, then redirects the browser straight
18 + # back to the SPA callback with ?access_token=… (or ?error=…).
19 + # 3. The SPA stores that access_token as its bearer — it's the very token
20 + # Api::BaseController#authenticate_token! validates against smbCloud,
21 + # so no extra exchange step is needed.
22 + #
23 + # The redirect_uri is validated against an allowlist so this can't be turned
24 + # into an open redirector that leaks tokens to an attacker-controlled origin.
25 + #
26 + # Subclasses supply the provider specifics:
27 + # provider — path segment, e.g. "github" (also the SPA callback path)
28 + # authorize_url — the smbCloud authorize URL for a validated redirect_uri
29 + class BrokeredController < Api::BaseController
30 + # GET /api/v1/auth/<provider>?redirect_uri=…
31 + def start
32 + redirect_uri = allowed_redirect_uri(params[:redirect_uri])
33 + unless redirect_uri
34 + return render_error(ERR_INVALID, "Unsupported redirect_uri.", status: :unprocessable_entity)
35 + end
36 +
37 + redirect_to authorize_url(redirect_uri), allow_other_host: true
38 + rescue KeyError => e
39 + Rails.logger.error("smbCloud configuration error during #{provider} sign-in: #{e.message}")
40 + render_error(ERR_UNKNOWN, "Authentication service is not configured.", status: :internal_server_error)
41 + end
42 +
43 + private
44 +
45 + # Returns the requested redirect_uri only when its origin is allowlisted
46 + # and its path is the SPA callback, otherwise nil. Keeps the brokered
47 + # access_token from ever being redirected somewhere we don't control.
48 + def allowed_redirect_uri(raw)
49 + return nil if raw.blank?
50 +
51 + uri = URI.parse(raw)
52 + return nil unless %w[http https].include?(uri.scheme)
53 + return nil if uri.host.blank?
54 + return nil unless uri.path.to_s.end_with?("/auth/#{provider}/callback")
55 +
56 + allowed_origins.include?(origin_of(uri)) ? raw : nil
57 + rescue URI::InvalidURIError
58 + nil
59 + end
60 +
61 + # scheme://host[:port], dropping the port when it's the scheme default so
62 + # "https://code.sigit.si:443" matches an allowlisted "https://code.sigit.si".
63 + def origin_of(uri)
64 + default = (uri.scheme == "http" && uri.port == 80) || (uri.scheme == "https" && uri.port == 443)
65 + default ? "#{uri.scheme}://#{uri.host}" : "#{uri.scheme}://#{uri.host}:#{uri.port}"
66 + end
67 +
68 + # Origins permitted to receive the brokered access_token. Configurable via
69 + # CODE_CLOUD_ALLOWED_ORIGINS (comma-separated); defaults cover the
70 + # production SPA and the local dev server (vite, port 5180).
71 + def allowed_origins
72 + ENV.fetch("CODE_CLOUD_ALLOWED_ORIGINS", "https://code.sigit.si,http://localhost:5180")
73 + .split(",").map(&:strip).reject(&:blank?)
74 + end
75 + end
76 + end
77 + end
78 +end
app/controllers/api/v1/oauth/github_controller.rb
+6 -61
@@ -3,71 +3,16 @@
3 module Api
4 module V1
5 module Oauth
6 - # "Continue with GitHub" for browser SPA clients (code.sigit.si).
7 - #
8 - # The web app (Oauth::GithubController) keeps the whole flow on sigit.si and
9 - # establishes a cookie session. A client-only SPA can't do that: it has no
10 - # server to hold the smbCloud app secret, and it needs a *token* back, not a
11 - # cookie. So this endpoint plays the server half of the dance:
12 - #
13 - # 1. #start (here) — the SPA points the browser at
14 - # GET /api/v1/auth/github?redirect_uri=<spa>/auth/github/callback.
15 - # We inject the app secret server-side and 302 to smbCloud's authorize
16 - # URL, carrying the SPA's callback as the redirect_uri.
17 - # 2. smbCloud brokers GitHub, then redirects the browser straight back to
18 - # the SPA callback with ?access_token=… (or ?error=…).
19 - # 3. The SPA stores that access_token as its bearer — it's the very token
20 - # Api::BaseController#authenticate_token! validates against smbCloud,
21 - # so no extra exchange step is needed.
22 - #
23 - # The redirect_uri is validated against an allowlist so this can't be turned
24 - # into an open redirector that leaks tokens to an attacker-controlled origin.
25 - class GithubController < Api::BaseController
26 - # GET /api/v1/auth/github?redirect_uri=…
27 - def start
28 - redirect_uri = allowed_redirect_uri(params[:redirect_uri])
29 - unless redirect_uri
30 - return render_error(ERR_INVALID, "Unsupported redirect_uri.", status: :unprocessable_entity)
31 - end
32 -
33 - redirect_to SmbcloudAuthService.github_authorize_url(redirect_uri: redirect_uri),
34 - allow_other_host: true
35 - rescue KeyError => e
36 - Rails.logger.error("smbCloud configuration error during GitHub sign-in: #{e.message}")
37 - render_error(ERR_UNKNOWN, "Authentication service is not configured.", status: :internal_server_error)
38 - end
39 -
6 + # "Continue with GitHub" for browser SPA clients — see BrokeredController.
7 + class GithubController < BrokeredController
8 private
9
42 - # Returns the requested redirect_uri only when its origin is allowlisted
43 - # and its path is the SPA callback, otherwise nil. Keeps the brokered
44 - # access_token from ever being redirected somewhere we don't control.
45 - def allowed_redirect_uri(raw)
46 - return nil if raw.blank?
47 -
48 - uri = URI.parse(raw)
49 - return nil unless %w[http https].include?(uri.scheme)
50 - return nil if uri.host.blank?
51 - return nil unless uri.path.to_s.end_with?("/auth/github/callback")
52 -
53 - allowed_origins.include?(origin_of(uri)) ? raw : nil
54 - rescue URI::InvalidURIError
55 - nil
56 - end
57 -
58 - # scheme://host[:port], dropping the port when it's the scheme default so
59 - # "https://code.sigit.si:443" matches an allowlisted "https://code.sigit.si".
60 - def origin_of(uri)
61 - default = (uri.scheme == "http" && uri.port == 80) || (uri.scheme == "https" && uri.port == 443)
62 - default ? "#{uri.scheme}://#{uri.host}" : "#{uri.scheme}://#{uri.host}:#{uri.port}"
10 + def provider
11 + "github"
12 end
13
65 - # Origins permitted to receive the brokered access_token. Configurable via
66 - # CODE_CLOUD_ALLOWED_ORIGINS (comma-separated); defaults cover the
67 - # production SPA and the local dev server (vite, port 5180).
68 - def allowed_origins
69 - ENV.fetch("CODE_CLOUD_ALLOWED_ORIGINS", "https://code.sigit.si,http://localhost:5180")
70 - .split(",").map(&:strip).reject(&:blank?)
14 + def authorize_url(redirect_uri)
15 + SmbcloudAuthService.github_authorize_url(redirect_uri: redirect_uri)
16 end
17 end
18 end
app/controllers/api/v1/oauth/google_controller.rb new
+20
@@ -0,0 +1,20 @@
1 +# frozen_string_literal: true
2 +
3 +module Api
4 + module V1
5 + module Oauth
6 + # "Continue with Google" for browser SPA clients — see BrokeredController.
7 + class GoogleController < BrokeredController
8 + private
9 +
10 + def provider
11 + "google"
12 + end
13 +
14 + def authorize_url(redirect_uri)
15 + SmbcloudAuthService.google_authorize_url(redirect_uri: redirect_uri)
16 + end
17 + end
18 + end
19 + end
20 +end
app/controllers/oauth/github_controller.rb
+7 -49
@@ -1,58 +1,16 @@
1 # frozen_string_literal: true
2
3 module Oauth
4 - # "Continue with GitHub" — delegates to smbCloud Auth, which brokers the
5 - # GitHub OAuth flow (smbCloud is our Auth0-style auth-as-a-service). smbCloud
6 - # signs the user in / creates the account from their GitHub profile, then
7 - # redirects back to #callback with an `access_token` we exchange for a local
8 - # session — the same upsert path as SessionsController#create.
9 - class GithubController < ApplicationController
10 - before_action :redirect_if_signed_in
11 -
12 - # GET /auth/github
13 - def start
14 - redirect_to SmbcloudAuthService.github_authorize_url(redirect_uri: github_auth_callback_url),
15 - allow_other_host: true
16 - end
17 -
18 - # GET /auth/github/callback
19 - def callback
20 - if params[:error].present?
21 - Rails.logger.warn("GitHub sign-in error: #{params[:error]}")
22 - return redirect_to signin_path, alert: "GitHub sign-in was cancelled or failed. Please try again."
23 - end
24 -
25 - access_token = params[:access_token].to_s
26 - if access_token.blank?
27 - return redirect_to signin_path, alert: "GitHub sign-in failed. Please try again."
28 - end
29 -
30 - profile = SmbcloudAuthService.me(access_token: access_token)
31 - user = User.find_or_create_from_smbcloud(profile.merge(access_token: access_token))
32 -
33 - reset_session
34 - session[:user_id] = user.id
35 -
36 - return_to = session.delete(:return_to)
37 - redirect_to(return_to || root_path, notice: "Welcome, #{user.display_name_or_username}!")
38 -
39 - rescue SmbcloudAuthService::AuthenticationError => e
40 - Rails.logger.warn("GitHub sign-in auth error: #{e.message}")
41 - redirect_to signin_path, alert: "GitHub sign-in failed. Please try again."
42 -
43 - rescue KeyError => e
44 - Rails.logger.error("smbCloud configuration error during GitHub sign-in: #{e.message}")
45 - redirect_to signin_path, alert: "Authentication service is not configured. Please contact support."
4 + # "Continue with GitHub" — see Oauth::ProviderController for the flow.
5 + class GithubController < ProviderController
6 + private
7
47 - rescue => e
48 - Rails.logger.error("Unexpected GitHub sign-in error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
49 - redirect_to signin_path, alert: "An unexpected error occurred. Please try again."
8 + def provider_label
9 + "GitHub"
10 end
11
52 - private
53 -
54 - def redirect_if_signed_in
55 - redirect_to root_path, notice: "You are already signed in." if signed_in?
12 + def authorize_url
13 + SmbcloudAuthService.github_authorize_url(redirect_uri: github_auth_callback_url)
14 end
15 end
16 end
app/controllers/oauth/google_controller.rb new
+16
@@ -0,0 +1,16 @@
1 +# frozen_string_literal: true
2 +
3 +module Oauth
4 + # "Continue with Google" — see Oauth::ProviderController for the flow.
5 + class GoogleController < ProviderController
6 + private
7 +
8 + def provider_label
9 + "Google"
10 + end
11 +
12 + def authorize_url
13 + SmbcloudAuthService.google_authorize_url(redirect_uri: google_auth_callback_url)
14 + end
15 + end
16 +end
app/controllers/oauth/provider_controller.rb new
+62
@@ -0,0 +1,62 @@
1 +# frozen_string_literal: true
2 +
3 +module Oauth
4 + # Shared "Continue with <provider>" flow — delegates to smbCloud Auth, which
5 + # brokers the provider's OAuth dance (smbCloud is our Auth0-style
6 + # auth-as-a-service). smbCloud signs the user in / creates the account from
7 + # the provider profile, then redirects back to #callback with an
8 + # `access_token` we exchange for a local session — the same upsert path as
9 + # SessionsController#create.
10 + #
11 + # Subclasses supply the provider specifics:
12 + # provider_label — human name used in flash/log messages ("GitHub")
13 + # authorize_url — the smbCloud authorize URL carrying our callback
14 + class ProviderController < ApplicationController
15 + before_action :redirect_if_signed_in
16 +
17 + # GET /auth/<provider>
18 + def start
19 + redirect_to authorize_url, allow_other_host: true
20 + end
21 +
22 + # GET /auth/<provider>/callback
23 + def callback
24 + if params[:error].present?
25 + Rails.logger.warn("#{provider_label} sign-in error: #{params[:error]}")
26 + return redirect_to signin_path, alert: "#{provider_label} sign-in was cancelled or failed. Please try again."
27 + end
28 +
29 + access_token = params[:access_token].to_s
30 + if access_token.blank?
31 + return redirect_to signin_path, alert: "#{provider_label} sign-in failed. Please try again."
32 + end
33 +
34 + profile = SmbcloudAuthService.me(access_token: access_token)
35 + user = User.find_or_create_from_smbcloud(profile.merge(access_token: access_token))
36 +
37 + reset_session
38 + session[:user_id] = user.id
39 +
40 + return_to = session.delete(:return_to)
41 + redirect_to(return_to || root_path, notice: "Welcome, #{user.display_name_or_username}!")
42 +
43 + rescue SmbcloudAuthService::AuthenticationError => e
44 + Rails.logger.warn("#{provider_label} sign-in auth error: #{e.message}")
45 + redirect_to signin_path, alert: "#{provider_label} sign-in failed. Please try again."
46 +
47 + rescue KeyError => e
48 + Rails.logger.error("smbCloud configuration error during #{provider_label} sign-in: #{e.message}")
49 + redirect_to signin_path, alert: "Authentication service is not configured. Please contact support."
50 +
51 + rescue => e
52 + Rails.logger.error("Unexpected #{provider_label} sign-in error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
53 + redirect_to signin_path, alert: "An unexpected error occurred. Please try again."
54 + end
55 +
56 + private
57 +
58 + def redirect_if_signed_in
59 + redirect_to root_path, notice: "You are already signed in." if signed_in?
60 + end
61 + end
62 +end
app/services/smbcloud_auth_service.rb
+14 -4
@@ -83,11 +83,20 @@ class SmbcloudAuthService
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 - #
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).
89 - def self.github_authorize_url(redirect_uri:)
90 - uri = URI.parse("#{smbcloud_base_url}/v1/client/oauth/github/authorize")
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,
@@ -256,5 +265,6 @@ class SmbcloudAuthService
265 end
266
267 private_class_method :client, :resolved_environment_name, :smbcloud_environment,
259 - :smbcloud_base_url, :post_client, :smbcloud_app_id, :smbcloud_app_secret
268 + :smbcloud_base_url, :post_client, :smbcloud_app_id, :smbcloud_app_secret,
269 + :oauth_authorize_url
270 end
app/views/registrations/new.html.erb
+1 -1
@@ -59,7 +59,7 @@
59
60 <% end %>
61
62 - <%= render "sessions/github_button" %>
62 + <%= render "sessions/oauth_buttons" %>
63 </div>
64
65 <p class="mt-6 text-center text-xs text-gray-500">
app/views/sessions/_github_button.html.erb
-6
@@ -1,9 +1,3 @@
1 -<div class="my-5 flex items-center gap-3">
2 - <span class="h-px flex-1 bg-surface-500"></span>
3 - <span class="text-xs uppercase tracking-wide text-gray-500">or</span>
4 - <span class="h-px flex-1 bg-surface-500"></span>
5 -</div>
6 -
1 <%= link_to github_auth_path,
2 class: "btn-secondary w-full justify-center gap-2.5 py-2.5 inline-flex items-center" do %>
3 <svg class="w-5 h-5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
app/views/sessions/_google_button.html.erb new
+10
@@ -0,0 +1,10 @@
1 +<%= link_to google_auth_path,
2 + class: "btn-secondary w-full justify-center gap-2.5 py-2.5 inline-flex items-center" do %>
3 + <svg class="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
4 + <path fill="#4285F4" d="M23.49 12.27c0-.79-.07-1.54-.19-2.27H12v4.51h6.47c-.29 1.48-1.14 2.73-2.4 3.58v3h3.86c2.26-2.09 3.56-5.17 3.56-8.82z"/>
5 + <path fill="#34A853" d="M12 24c3.24 0 5.95-1.08 7.93-2.91l-3.86-3c-1.08.72-2.45 1.16-4.07 1.16-3.13 0-5.78-2.11-6.73-4.96H1.29v3.09C3.26 21.3 7.31 24 12 24z"/>
6 + <path fill="#FBBC05" d="M5.27 14.29c-.25-.72-.38-1.49-.38-2.29s.13-1.57.38-2.29V6.62H1.29A11.97 11.97 0 0 0 0 12c0 1.94.47 3.76 1.29 5.38l3.98-3.09z"/>
7 + <path fill="#EA4335" d="M12 4.75c1.77 0 3.35.61 4.6 1.8l3.42-3.42C17.95 1.19 15.24 0 12 0 7.31 0 3.26 2.7 1.29 6.62l3.98 3.09c.95-2.85 3.6-4.96 6.73-4.96z"/>
8 + </svg>
9 + <span>Continue with Google</span>
10 +<% end %>
app/views/sessions/_oauth_buttons.html.erb new
+10
@@ -0,0 +1,10 @@
1 +<div class="my-5 flex items-center gap-3">
2 + <span class="h-px flex-1 bg-surface-500"></span>
3 + <span class="text-xs uppercase tracking-wide text-gray-500">or</span>
4 + <span class="h-px flex-1 bg-surface-500"></span>
5 +</div>
6 +
7 +<div class="space-y-3">
8 + <%= render "sessions/github_button" %>
9 + <%= render "sessions/google_button" %>
10 +</div>
app/views/sessions/new.html.erb
+1 -1
@@ -49,7 +49,7 @@
49
50 <% end %>
51
52 - <%= render "sessions/github_button" %>
52 + <%= render "sessions/oauth_buttons" %>
53 </div>
54
55 <p class="mt-6 text-center text-xs text-gray-500">
config/routes.rb
+8 -5
@@ -33,9 +33,11 @@ Rails.application.routes.draw do
33 post "/auth", to: "sessions#create", as: :auth_create
34 delete "/auth/signout", to: "sessions#destroy", as: :signout
35
36 - # Auth — "Continue with GitHub" (brokered by smbCloud Auth)
36 + # Auth — "Continue with GitHub" / "Continue with Google" (brokered by smbCloud Auth)
37 get "/auth/github", to: "oauth/github#start", as: :github_auth
38 get "/auth/github/callback", to: "oauth/github#callback", as: :github_auth_callback
39 + get "/auth/google", to: "oauth/google#start", as: :google_auth
40 + get "/auth/google/callback", to: "oauth/google#callback", as: :google_auth_callback
41
42 # Signup
43 get "/auth/signup", to: "registrations#new", as: :signup
@@ -103,11 +105,12 @@ Rails.application.routes.draw do
105 post "auth/sign_up", to: "registrations#create"
106 post "auth/confirmation/resend", to: "confirmations#create"
107 post "auth/password/reset", to: "passwords#create"
106 - # "Continue with GitHub" for browser SPA clients (code.sigit.si). A
107 - # server-side redirect that brokers the smbCloud GitHub flow so the app
108 - # secret never reaches the client; smbCloud bounces back to the SPA's
109 - # redirect_uri with an access_token (the same bearer the API expects).
108 + # "Continue with GitHub" / "Continue with Google" for browser SPA clients
109 + # (code.sigit.si). A server-side redirect that brokers the smbCloud flow
110 + # so the app secret never reaches the client; smbCloud bounces back to the
111 + # SPA's redirect_uri with an access_token (the same bearer the API expects).
112 get "auth/github", to: "oauth/github#start"
113 + get "auth/google", to: "oauth/google#start"
114 get "me", to: "me#show"
115 delete "me", to: "me#destroy"
116 get "repos", to: "repos#index"
spec/requests/api/v1/oauth/google_spec.rb new
+58
@@ -0,0 +1,58 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +RSpec.describe "Api::V1::Oauth::Google", type: :request do
6 + let(:authorize_url) { "https://api.smbcloud.xyz/v1/client/oauth/google/authorize?client_id=app" }
7 +
8 + # Keep the spec off the smbcloud-auth native extension: stub the URL builder
9 + # and assert we forward the (validated) redirect_uri through to it. The
10 + # allowlist relies on the controller's built-in defaults (code.sigit.si +
11 + # localhost:5180), so no ENV juggling is needed.
12 + before do
13 + allow(SmbcloudAuthService).to receive(:google_authorize_url).and_return(authorize_url)
14 + end
15 +
16 + describe "GET /api/v1/auth/google" do
17 + it "redirects to the smbCloud authorize URL for an allowlisted redirect_uri" do
18 + redirect_uri = "https://code.sigit.si/auth/google/callback"
19 +
20 + get "/api/v1/auth/google", params: { redirect_uri: redirect_uri }
21 +
22 + expect(SmbcloudAuthService).to have_received(:google_authorize_url).with(redirect_uri: redirect_uri)
23 + expect(response).to redirect_to(authorize_url)
24 + end
25 +
26 + it "allows the local dev origin (default port omitted)" do
27 + get "/api/v1/auth/google", params: { redirect_uri: "http://localhost:5180/auth/google/callback" }
28 +
29 + expect(response).to have_http_status(:found)
30 + end
31 +
32 + it "rejects a redirect_uri whose origin is not allowlisted" do
33 + get "/api/v1/auth/google", params: { redirect_uri: "https://evil.example.com/auth/google/callback" }
34 +
35 + expect(SmbcloudAuthService).not_to have_received(:google_authorize_url)
36 + expect(response).to have_http_status(:unprocessable_entity)
37 + expect(response.parsed_body["message"]).to eq("Unsupported redirect_uri.")
38 + end
39 +
40 + it "rejects a redirect_uri with the wrong callback path" do
41 + get "/api/v1/auth/google", params: { redirect_uri: "https://code.sigit.si/somewhere-else" }
42 +
43 + expect(response).to have_http_status(:unprocessable_entity)
44 + end
45 +
46 + it "rejects a GitHub callback path on the Google endpoint" do
47 + get "/api/v1/auth/google", params: { redirect_uri: "https://code.sigit.si/auth/github/callback" }
48 +
49 + expect(response).to have_http_status(:unprocessable_entity)
50 + end
51 +
52 + it "rejects a missing redirect_uri" do
53 + get "/api/v1/auth/google"
54 +
55 + expect(response).to have_http_status(:unprocessable_entity)
56 + end
57 + end
58 +end