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
index e26dcee..5b1dd48 100644
--- a/.agents/skills/local-environment/SKILL.md
+++ b/.agents/skills/local-environment/SKILL.md
@@ -1,6 +1,69 @@
---
name: local-environment
-description: A skill for working with the local environment for sigit.si
+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).
---
> **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).
+
+## Running the full local stack
+
+Two processes make local sign-in work end to end (verified 2026-07-02):
+
+1. **sigit-si** — `bin/dev` (Puma on **:3000** + Tailwind watcher). Do NOT use a
+ bare `bin/rails server`: it neither builds `app/assets/builds/tailwind.css`
+ (→ `Propshaft::MissingAssetError` on every page) nor is there any other
+ process to build it. One-off alternative: `bin/rails tailwindcss:build`.
+2. **smbcloud-api** — the local auth backend at
+ `~/Repositories/smbcloud/services/smbcloud-api`, `bin/rails server`
+ (Puma on **:8088**, from `API_SMBCLOUD_XYZ_PORT`). Required whenever
+ `SIGITSI_SMBCLOUD_ENVIRONMENT=dev`, because the smbcloud-auth gem's dev
+ environment hard-points at `http://localhost:8088`. If it isn't running,
+ sign-in fails with a network error.
+
+## Environment (.env)
+
+- `.env` at the repo root is gitignored and **required**:
+ `SIGITSI_SMBCLOUD_APP_ID`, `SIGITSI_SMBCLOUD_APP_SECRET`,
+ `SIGITSI_SMBCLOUD_ENVIRONMENT` (`dev` → localhost:8088, `production` →
+ api.smbcloud.xyz). Missing vars surface as the flash
+ **"Authentication service is not configured. Please contact support."**
+ (controllers rescue the `KeyError` from `ENV.fetch`).
+- `bin/dev` (foreman) auto-loads `.env`; a bare `rails server`/`rails runner`
+ does not (`set -a && . ./.env && set +a` first if needed).
+- **Git worktrees don't inherit `.env`** (gitignored). In a worktree, symlink
+ it: `ln -s ~/Repositories/sigit-si/.env .env`. Same story for
+ `app/assets/builds/` artifacts — rebuild them in the worktree.
+
+## Test accounts (local dev)
+
+The local smbcloud-api Postgres DB (`smbpndk_api_development`) has the sigit-si
+tenant registered as auth_app **"siGit Code & Deploy"**
+(`880e7dea-aacb-4ca4-a059-3a649a98f86a` = `SIGITSI_SMBCLOUD_APP_ID`; users were
+migrated here from the legacy "Rumi App Auth" app on 2026-07-02). Its
+`auth_users` (all confirmed) include `test@test.com`, `setoelkahfi@gmail.com`,
+`seto@smbcloud.xyz` — passwords are bcrypt-hashed, so use one you know, sign up
+a fresh local account, or reset via the smbcloud-api console. Inspect with:
+
+```sh
+psql -h localhost -d smbpndk_api_development \
+ -c "select id, email, confirmed_at is not null from auth_users
+ where auth_app_id = '880e7dea-aacb-4ca4-a059-3a649a98f86a';"
+```
+
+Note: the platform-level seed accounts in smbcloud-api
+(`db/seeds/demo_accounts.rb`, e.g. `demo@smbcloud.xyz`) are platform `users`,
+**not** sigit-si tenant users — they will not work on the sigit-si login form.
+The production demo account is `demo@sigit.si` (prod credential).
+
+## Known local-only failure modes
+
+- **`LoadError: ... auth.bundle ... load command #4 string extends beyond end
+ of load command`** on macOS 26/27 — broken `LC_ID_DYLIB` in the
+ smbcloud-auth native extension; fix and band-aid documented in
+ [smbcloud-auth](../smbcloud-auth/SKILL.md) under "macOS 26/27".
+- **`The asset 'tailwind.css' was not found in the load path`** — CSS never
+ built in this checkout/worktree; run `bin/dev` or `bin/rails tailwindcss:build`.
+- **"Authentication service is not configured."** — `.env` missing/not loaded
+ (see above).
+- **Network error on sign-in with `SIGITSI_SMBCLOUD_ENVIRONMENT=dev`** — local
+ smbcloud-api not running on :8088.
.agents/skills/smbcloud-auth/SKILL.md
+46
-5
index 7c9994a..93b7900 100644
--- a/.agents/skills/smbcloud-auth/SKILL.md
+++ b/.agents/skills/smbcloud-auth/SKILL.md
@@ -23,13 +23,22 @@ SDKs, the web console), see the authoritative skill in
Do not call `SmbCloud::Auth` directly from controllers; go through this service.
- `app/controllers/sessions_controller.rb` — HTML sign in / sign out (`/auth`).
- `app/controllers/registrations_controller.rb` — HTML sign up (`/auth/signup`).
-- `app/controllers/oauth/github_controller.rb` — "Continue with GitHub"
- (`/auth/github` → smbCloud authorize; `/auth/github/callback` consumes the
- returned `access_token`). smbCloud brokers the GitHub OAuth flow; this app only
- starts it and reuses the same `me` → upsert → session path as `sessions#create`.
+- `app/controllers/oauth/provider_controller.rb` — shared "Continue with
+ <provider>" flow (`/auth/<provider>` → smbCloud authorize;
+ `/auth/<provider>/callback` consumes the returned `access_token`). smbCloud
+ brokers the provider OAuth flow; this app only starts it and reuses the same
+ `me` → upsert → session path as `sessions#create`. Thin subclasses:
+ `oauth/github_controller.rb`, `oauth/google_controller.rb`. The SPA
+ (code.sigit.si) equivalents live under `api/v1/oauth/` (`brokered_controller.rb`
+ base + per-provider subclasses) with a redirect_uri allowlist.
+ Adding a provider = two ~15-line subclasses + `SmbcloudAuthService.<p>_authorize_url`
+ + routes + a button partial — provided smbcloud-api brokers it and the
+ AuthApp has the provider's OAuth client configured (e.g.
+ `google_oauth_client_id/secret` on the auth app; without it the broker
+ returns "Google sign-in is not configured for this app").
- `app/models/user.rb` — local mirror, upserted via
`User.find_or_create_from_smbcloud(profile.merge(access_token:))`.
-- `Gemfile` — `gem "smbcloud-auth", "~> 0.3.35"` (native Rust/Magnus extension).
+- `Gemfile` — `gem "smbcloud-auth", "~> 0.4.5"` (native Rust/Magnus extension).
## SmbcloudAuthService surface
@@ -113,6 +122,38 @@ This affects every published version of the gem, not a specific one. If a
the **gem** (bump `magnus`/`rb_sys` in its `ext/auth/Cargo.toml` and republish)
or build under Ruby 3.3.x — not in this repo's Gemfile.
+### macOS 26/27: dlopen rejects the built bundle (empty LC_ID_DYLIB)
+
+On macOS 26/27, a source-gem install of `smbcloud-auth` builds fine but fails
+at runtime with:
+
+```
+LoadError (dlopen(.../smbcloud-auth-X.Y.Z/lib/auth/auth.bundle):
+ load command #4 string extends beyond end of load command)
+```
+
+Every request that touches `SmbcloudAuthService` then 500s (it does
+`require "auth"` at file-eval). Root cause: rb-sys's generated Makefile
+(`fixup_libnames` in `rb_sys/mkmf.rb`) ends the build with
+`install_name_tool -id "" $(DLLIB)`, leaving an **empty `LC_ID_DYLIB` install
+name**, which the stricter dyld on macOS 26/27 rejects. `gem pristine` does
+NOT help — it rebuilds through the same broken step.
+
+- **Durable fix** lives in the gem source (`smbcloud-cli` repo, merged to
+ `development` 2026-07-02): `sdk/gems/auth/ext/auth/extconf.rb` post-processes
+ the generated Makefile to stamp `-id "@rpath/auth.bundle"` (covers
+ `gem install`), and the gem's `Rakefile` does the same for `rake native gem`
+ builds. Ships in the next gem release after 0.4.5.
+- **Local band-aid** if a broken bundle is already installed:
+
+ ```sh
+ install_name_tool -id "@rpath/auth.bundle" <path>/auth.bundle
+ codesign -f -s - <path>/auth.bundle
+ ```
+
+- Diagnose with `otool -l auth.bundle | grep -A3 LC_ID_DYLIB` — an empty
+ `name (offset 24)` is the broken state.
+
## Planned: /api/v1 JSON auth for the desktop app
The "siGit Code & Deploy" Tauri app (`sigit-app`) needs a JSON, token-based auth
app/controllers/api/v1/oauth/brokered_controller.rb
+78
new file mode 100644
index 0000000..8076047
--- /dev/null
+++ b/app/controllers/api/v1/oauth/brokered_controller.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ module Oauth
+ # Brokered social sign-in for browser SPA clients (code.sigit.si).
+ #
+ # The web app (Oauth::ProviderController) keeps the whole flow on sigit.si
+ # and establishes a cookie session. A client-only SPA can't do that: it has
+ # no server to hold the smbCloud app secret, and it needs a *token* back,
+ # not a cookie. So this endpoint plays the server half of the dance:
+ #
+ # 1. #start (here) — the SPA points the browser at
+ # GET /api/v1/auth/<provider>?redirect_uri=<spa>/auth/<provider>/callback.
+ # We inject the app secret server-side and 302 to smbCloud's authorize
+ # URL, carrying the SPA's callback as the redirect_uri.
+ # 2. smbCloud brokers the provider, then redirects the browser straight
+ # back to the SPA callback with ?access_token=… (or ?error=…).
+ # 3. The SPA stores that access_token as its bearer — it's the very token
+ # Api::BaseController#authenticate_token! validates against smbCloud,
+ # so no extra exchange step is needed.
+ #
+ # The redirect_uri is validated against an allowlist so this can't be turned
+ # into an open redirector that leaks tokens to an attacker-controlled origin.
+ #
+ # Subclasses supply the provider specifics:
+ # provider — path segment, e.g. "github" (also the SPA callback path)
+ # authorize_url — the smbCloud authorize URL for a validated redirect_uri
+ class BrokeredController < Api::BaseController
+ # GET /api/v1/auth/<provider>?redirect_uri=…
+ def start
+ redirect_uri = allowed_redirect_uri(params[:redirect_uri])
+ unless redirect_uri
+ return render_error(ERR_INVALID, "Unsupported redirect_uri.", status: :unprocessable_entity)
+ end
+
+ redirect_to authorize_url(redirect_uri), allow_other_host: true
+ rescue KeyError => e
+ Rails.logger.error("smbCloud configuration error during #{provider} sign-in: #{e.message}")
+ render_error(ERR_UNKNOWN, "Authentication service is not configured.", status: :internal_server_error)
+ end
+
+ private
+
+ # Returns the requested redirect_uri only when its origin is allowlisted
+ # and its path is the SPA callback, otherwise nil. Keeps the brokered
+ # access_token from ever being redirected somewhere we don't control.
+ def allowed_redirect_uri(raw)
+ return nil if raw.blank?
+
+ uri = URI.parse(raw)
+ return nil unless %w[http https].include?(uri.scheme)
+ return nil if uri.host.blank?
+ return nil unless uri.path.to_s.end_with?("/auth/#{provider}/callback")
+
+ allowed_origins.include?(origin_of(uri)) ? raw : nil
+ rescue URI::InvalidURIError
+ nil
+ end
+
+ # scheme://host[:port], dropping the port when it's the scheme default so
+ # "https://code.sigit.si:443" matches an allowlisted "https://code.sigit.si".
+ def origin_of(uri)
+ default = (uri.scheme == "http" && uri.port == 80) || (uri.scheme == "https" && uri.port == 443)
+ default ? "#{uri.scheme}://#{uri.host}" : "#{uri.scheme}://#{uri.host}:#{uri.port}"
+ end
+
+ # Origins permitted to receive the brokered access_token. Configurable via
+ # CODE_CLOUD_ALLOWED_ORIGINS (comma-separated); defaults cover the
+ # production SPA and the local dev server (vite, port 5180).
+ def allowed_origins
+ ENV.fetch("CODE_CLOUD_ALLOWED_ORIGINS", "https://code.sigit.si,http://localhost:5180")
+ .split(",").map(&:strip).reject(&:blank?)
+ end
+ end
+ end
+ end
+end
app/controllers/api/v1/oauth/github_controller.rb
+6
-61
index 1c3e07c..c1759ef 100644
--- a/app/controllers/api/v1/oauth/github_controller.rb
+++ b/app/controllers/api/v1/oauth/github_controller.rb
@@ -3,71 +3,16 @@
module Api
module V1
module Oauth
- # "Continue with GitHub" for browser SPA clients (code.sigit.si).
- #
- # The web app (Oauth::GithubController) keeps the whole flow on sigit.si and
- # establishes a cookie session. A client-only SPA can't do that: it has no
- # server to hold the smbCloud app secret, and it needs a *token* back, not a
- # cookie. So this endpoint plays the server half of the dance:
- #
- # 1. #start (here) — the SPA points the browser at
- # GET /api/v1/auth/github?redirect_uri=<spa>/auth/github/callback.
- # We inject the app secret server-side and 302 to smbCloud's authorize
- # URL, carrying the SPA's callback as the redirect_uri.
- # 2. smbCloud brokers GitHub, then redirects the browser straight back to
- # the SPA callback with ?access_token=… (or ?error=…).
- # 3. The SPA stores that access_token as its bearer — it's the very token
- # Api::BaseController#authenticate_token! validates against smbCloud,
- # so no extra exchange step is needed.
- #
- # The redirect_uri is validated against an allowlist so this can't be turned
- # into an open redirector that leaks tokens to an attacker-controlled origin.
- class GithubController < Api::BaseController
- # GET /api/v1/auth/github?redirect_uri=…
- def start
- redirect_uri = allowed_redirect_uri(params[:redirect_uri])
- unless redirect_uri
- return render_error(ERR_INVALID, "Unsupported redirect_uri.", status: :unprocessable_entity)
- end
-
- redirect_to SmbcloudAuthService.github_authorize_url(redirect_uri: redirect_uri),
- allow_other_host: true
- rescue KeyError => e
- Rails.logger.error("smbCloud configuration error during GitHub sign-in: #{e.message}")
- render_error(ERR_UNKNOWN, "Authentication service is not configured.", status: :internal_server_error)
- end
-
+ # "Continue with GitHub" for browser SPA clients — see BrokeredController.
+ class GithubController < BrokeredController
private
- # Returns the requested redirect_uri only when its origin is allowlisted
- # and its path is the SPA callback, otherwise nil. Keeps the brokered
- # access_token from ever being redirected somewhere we don't control.
- def allowed_redirect_uri(raw)
- return nil if raw.blank?
-
- uri = URI.parse(raw)
- return nil unless %w[http https].include?(uri.scheme)
- return nil if uri.host.blank?
- return nil unless uri.path.to_s.end_with?("/auth/github/callback")
-
- allowed_origins.include?(origin_of(uri)) ? raw : nil
- rescue URI::InvalidURIError
- nil
- end
-
- # scheme://host[:port], dropping the port when it's the scheme default so
- # "https://code.sigit.si:443" matches an allowlisted "https://code.sigit.si".
- def origin_of(uri)
- default = (uri.scheme == "http" && uri.port == 80) || (uri.scheme == "https" && uri.port == 443)
- default ? "#{uri.scheme}://#{uri.host}" : "#{uri.scheme}://#{uri.host}:#{uri.port}"
+ def provider
+ "github"
end
- # Origins permitted to receive the brokered access_token. Configurable via
- # CODE_CLOUD_ALLOWED_ORIGINS (comma-separated); defaults cover the
- # production SPA and the local dev server (vite, port 5180).
- def allowed_origins
- ENV.fetch("CODE_CLOUD_ALLOWED_ORIGINS", "https://code.sigit.si,http://localhost:5180")
- .split(",").map(&:strip).reject(&:blank?)
+ def authorize_url(redirect_uri)
+ SmbcloudAuthService.github_authorize_url(redirect_uri: redirect_uri)
end
end
end
app/controllers/api/v1/oauth/google_controller.rb
+20
new file mode 100644
index 0000000..bb7e94f
--- /dev/null
+++ b/app/controllers/api/v1/oauth/google_controller.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ module Oauth
+ # "Continue with Google" for browser SPA clients — see BrokeredController.
+ class GoogleController < BrokeredController
+ private
+
+ def provider
+ "google"
+ end
+
+ def authorize_url(redirect_uri)
+ SmbcloudAuthService.google_authorize_url(redirect_uri: redirect_uri)
+ end
+ end
+ end
+ end
+end
app/controllers/oauth/github_controller.rb
+7
-49
index 11f60c0..5080f48 100644
--- a/app/controllers/oauth/github_controller.rb
+++ b/app/controllers/oauth/github_controller.rb
@@ -1,58 +1,16 @@
# frozen_string_literal: true
module Oauth
- # "Continue with GitHub" — delegates to smbCloud Auth, which brokers the
- # GitHub OAuth flow (smbCloud is our Auth0-style auth-as-a-service). smbCloud
- # signs the user in / creates the account from their GitHub profile, then
- # redirects back to #callback with an `access_token` we exchange for a local
- # session — the same upsert path as SessionsController#create.
- class GithubController < ApplicationController
- before_action :redirect_if_signed_in
-
- # GET /auth/github
- def start
- redirect_to SmbcloudAuthService.github_authorize_url(redirect_uri: github_auth_callback_url),
- allow_other_host: true
- end
-
- # GET /auth/github/callback
- def callback
- if params[:error].present?
- Rails.logger.warn("GitHub sign-in error: #{params[:error]}")
- return redirect_to signin_path, alert: "GitHub sign-in was cancelled or failed. Please try again."
- end
-
- access_token = params[:access_token].to_s
- if access_token.blank?
- return redirect_to signin_path, alert: "GitHub sign-in failed. Please try again."
- end
-
- profile = SmbcloudAuthService.me(access_token: access_token)
- user = User.find_or_create_from_smbcloud(profile.merge(access_token: access_token))
-
- reset_session
- session[:user_id] = user.id
-
- return_to = session.delete(:return_to)
- redirect_to(return_to || root_path, notice: "Welcome, #{user.display_name_or_username}!")
-
- rescue SmbcloudAuthService::AuthenticationError => e
- Rails.logger.warn("GitHub sign-in auth error: #{e.message}")
- redirect_to signin_path, alert: "GitHub sign-in failed. Please try again."
-
- rescue KeyError => e
- Rails.logger.error("smbCloud configuration error during GitHub sign-in: #{e.message}")
- redirect_to signin_path, alert: "Authentication service is not configured. Please contact support."
+ # "Continue with GitHub" — see Oauth::ProviderController for the flow.
+ class GithubController < ProviderController
+ private
- rescue => e
- Rails.logger.error("Unexpected GitHub sign-in error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
- redirect_to signin_path, alert: "An unexpected error occurred. Please try again."
+ def provider_label
+ "GitHub"
end
- private
-
- def redirect_if_signed_in
- redirect_to root_path, notice: "You are already signed in." if signed_in?
+ def authorize_url
+ SmbcloudAuthService.github_authorize_url(redirect_uri: github_auth_callback_url)
end
end
end
app/controllers/oauth/google_controller.rb
+16
new file mode 100644
index 0000000..8cf98ff
--- /dev/null
+++ b/app/controllers/oauth/google_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Oauth
+ # "Continue with Google" — see Oauth::ProviderController for the flow.
+ class GoogleController < ProviderController
+ private
+
+ def provider_label
+ "Google"
+ end
+
+ def authorize_url
+ SmbcloudAuthService.google_authorize_url(redirect_uri: google_auth_callback_url)
+ end
+ end
+end
app/controllers/oauth/provider_controller.rb
+62
new file mode 100644
index 0000000..5a8e6e6
--- /dev/null
+++ b/app/controllers/oauth/provider_controller.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Oauth
+ # Shared "Continue with <provider>" flow — delegates to smbCloud Auth, which
+ # brokers the provider's OAuth dance (smbCloud is our Auth0-style
+ # auth-as-a-service). smbCloud signs the user in / creates the account from
+ # the provider profile, then redirects back to #callback with an
+ # `access_token` we exchange for a local session — the same upsert path as
+ # SessionsController#create.
+ #
+ # Subclasses supply the provider specifics:
+ # provider_label — human name used in flash/log messages ("GitHub")
+ # authorize_url — the smbCloud authorize URL carrying our callback
+ class ProviderController < ApplicationController
+ before_action :redirect_if_signed_in
+
+ # GET /auth/<provider>
+ def start
+ redirect_to authorize_url, allow_other_host: true
+ end
+
+ # GET /auth/<provider>/callback
+ def callback
+ if params[:error].present?
+ Rails.logger.warn("#{provider_label} sign-in error: #{params[:error]}")
+ return redirect_to signin_path, alert: "#{provider_label} sign-in was cancelled or failed. Please try again."
+ end
+
+ access_token = params[:access_token].to_s
+ if access_token.blank?
+ return redirect_to signin_path, alert: "#{provider_label} sign-in failed. Please try again."
+ end
+
+ profile = SmbcloudAuthService.me(access_token: access_token)
+ user = User.find_or_create_from_smbcloud(profile.merge(access_token: access_token))
+
+ reset_session
+ session[:user_id] = user.id
+
+ return_to = session.delete(:return_to)
+ redirect_to(return_to || root_path, notice: "Welcome, #{user.display_name_or_username}!")
+
+ rescue SmbcloudAuthService::AuthenticationError => e
+ Rails.logger.warn("#{provider_label} sign-in auth error: #{e.message}")
+ redirect_to signin_path, alert: "#{provider_label} sign-in failed. Please try again."
+
+ rescue KeyError => e
+ Rails.logger.error("smbCloud configuration error during #{provider_label} sign-in: #{e.message}")
+ redirect_to signin_path, alert: "Authentication service is not configured. Please contact support."
+
+ rescue => e
+ Rails.logger.error("Unexpected #{provider_label} sign-in error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
+ redirect_to signin_path, alert: "An unexpected error occurred. Please try again."
+ end
+
+ private
+
+ def redirect_if_signed_in
+ redirect_to root_path, notice: "You are already signed in." if signed_in?
+ end
+ end
+end
app/services/smbcloud_auth_service.rb
+14
-4
index a5205a8..e538c35 100644
--- a/app/services/smbcloud_auth_service.rb
+++ b/app/services/smbcloud_auth_service.rb
@@ -83,11 +83,20 @@ class SmbcloudAuthService
# Builds the smbCloud "Sign in with GitHub" authorize URL. The browser is
# redirected here; smbCloud brokers the GitHub OAuth dance and bounces back to
# `redirect_uri` with an `access_token` (or an `error`) query param.
- #
+ def self.github_authorize_url(redirect_uri:)
+ oauth_authorize_url(provider: "github", redirect_uri: redirect_uri)
+ end
+
+ # Same brokered flow for "Sign in with Google" (smbCloud runs the whole
+ # Google OAuth2/OIDC exchange server-side, like GitHub).
+ def self.google_authorize_url(redirect_uri:)
+ oauth_authorize_url(provider: "google", redirect_uri: redirect_uri)
+ end
+
# Mirrors the platform's existing client convention of passing the app's
# client_id/client_secret as query params (see #post_client).
- def self.github_authorize_url(redirect_uri:)
- uri = URI.parse("#{smbcloud_base_url}/v1/client/oauth/github/authorize")
+ def self.oauth_authorize_url(provider:, redirect_uri:)
+ uri = URI.parse("#{smbcloud_base_url}/v1/client/oauth/#{provider}/authorize")
uri.query = URI.encode_www_form(
client_id: smbcloud_app_id,
client_secret: smbcloud_app_secret,
@@ -256,5 +265,6 @@ class SmbcloudAuthService
end
private_class_method :client, :resolved_environment_name, :smbcloud_environment,
- :smbcloud_base_url, :post_client, :smbcloud_app_id, :smbcloud_app_secret
+ :smbcloud_base_url, :post_client, :smbcloud_app_id, :smbcloud_app_secret,
+ :oauth_authorize_url
end
app/views/registrations/new.html.erb
+1
-1
index f49260b..815f650 100644
--- a/app/views/registrations/new.html.erb
+++ b/app/views/registrations/new.html.erb
@@ -59,7 +59,7 @@
<% end %>
- <%= render "sessions/github_button" %>
+ <%= render "sessions/oauth_buttons" %>
</div>
<p class="mt-6 text-center text-xs text-gray-500">
app/views/sessions/_github_button.html.erb
-6
index 4fa561c..16a6ae7 100644
--- a/app/views/sessions/_github_button.html.erb
+++ b/app/views/sessions/_github_button.html.erb
@@ -1,9 +1,3 @@
-<div class="my-5 flex items-center gap-3">
- <span class="h-px flex-1 bg-surface-500"></span>
- <span class="text-xs uppercase tracking-wide text-gray-500">or</span>
- <span class="h-px flex-1 bg-surface-500"></span>
-</div>
-
<%= link_to github_auth_path,
class: "btn-secondary w-full justify-center gap-2.5 py-2.5 inline-flex items-center" do %>
<svg class="w-5 h-5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
app/views/sessions/_google_button.html.erb
+10
new file mode 100644
index 0000000..20f73c0
--- /dev/null
+++ b/app/views/sessions/_google_button.html.erb
@@ -0,0 +1,10 @@
+<%= link_to google_auth_path,
+ class: "btn-secondary w-full justify-center gap-2.5 py-2.5 inline-flex items-center" do %>
+ <svg class="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
+ <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"/>
+ <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"/>
+ <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"/>
+ <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"/>
+ </svg>
+ <span>Continue with Google</span>
+<% end %>
app/views/sessions/_oauth_buttons.html.erb
+10
new file mode 100644
index 0000000..49a26c3
--- /dev/null
+++ b/app/views/sessions/_oauth_buttons.html.erb
@@ -0,0 +1,10 @@
+<div class="my-5 flex items-center gap-3">
+ <span class="h-px flex-1 bg-surface-500"></span>
+ <span class="text-xs uppercase tracking-wide text-gray-500">or</span>
+ <span class="h-px flex-1 bg-surface-500"></span>
+</div>
+
+<div class="space-y-3">
+ <%= render "sessions/github_button" %>
+ <%= render "sessions/google_button" %>
+</div>
app/views/sessions/new.html.erb
+1
-1
index c07ed47..cb4836e 100644
--- a/app/views/sessions/new.html.erb
+++ b/app/views/sessions/new.html.erb
@@ -49,7 +49,7 @@
<% end %>
- <%= render "sessions/github_button" %>
+ <%= render "sessions/oauth_buttons" %>
</div>
<p class="mt-6 text-center text-xs text-gray-500">
config/routes.rb
+8
-5
index 375e196..add094f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -33,9 +33,11 @@ Rails.application.routes.draw do
post "/auth", to: "sessions#create", as: :auth_create
delete "/auth/signout", to: "sessions#destroy", as: :signout
- # Auth — "Continue with GitHub" (brokered by smbCloud Auth)
+ # Auth — "Continue with GitHub" / "Continue with Google" (brokered by smbCloud Auth)
get "/auth/github", to: "oauth/github#start", as: :github_auth
get "/auth/github/callback", to: "oauth/github#callback", as: :github_auth_callback
+ get "/auth/google", to: "oauth/google#start", as: :google_auth
+ get "/auth/google/callback", to: "oauth/google#callback", as: :google_auth_callback
# Signup
get "/auth/signup", to: "registrations#new", as: :signup
@@ -103,11 +105,12 @@ Rails.application.routes.draw do
post "auth/sign_up", to: "registrations#create"
post "auth/confirmation/resend", to: "confirmations#create"
post "auth/password/reset", to: "passwords#create"
- # "Continue with GitHub" for browser SPA clients (code.sigit.si). A
- # server-side redirect that brokers the smbCloud GitHub flow so the app
- # secret never reaches the client; smbCloud bounces back to the SPA's
- # redirect_uri with an access_token (the same bearer the API expects).
+ # "Continue with GitHub" / "Continue with Google" for browser SPA clients
+ # (code.sigit.si). A server-side redirect that brokers the smbCloud flow
+ # so the app secret never reaches the client; smbCloud bounces back to the
+ # SPA's redirect_uri with an access_token (the same bearer the API expects).
get "auth/github", to: "oauth/github#start"
+ get "auth/google", to: "oauth/google#start"
get "me", to: "me#show"
delete "me", to: "me#destroy"
get "repos", to: "repos#index"
spec/requests/api/v1/oauth/google_spec.rb
+58
new file mode 100644
index 0000000..9a5718c
--- /dev/null
+++ b/spec/requests/api/v1/oauth/google_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Api::V1::Oauth::Google", type: :request do
+ let(:authorize_url) { "https://api.smbcloud.xyz/v1/client/oauth/google/authorize?client_id=app" }
+
+ # Keep the spec off the smbcloud-auth native extension: stub the URL builder
+ # and assert we forward the (validated) redirect_uri through to it. The
+ # allowlist relies on the controller's built-in defaults (code.sigit.si +
+ # localhost:5180), so no ENV juggling is needed.
+ before do
+ allow(SmbcloudAuthService).to receive(:google_authorize_url).and_return(authorize_url)
+ end
+
+ describe "GET /api/v1/auth/google" do
+ it "redirects to the smbCloud authorize URL for an allowlisted redirect_uri" do
+ redirect_uri = "https://code.sigit.si/auth/google/callback"
+
+ get "/api/v1/auth/google", params: { redirect_uri: redirect_uri }
+
+ expect(SmbcloudAuthService).to have_received(:google_authorize_url).with(redirect_uri: redirect_uri)
+ expect(response).to redirect_to(authorize_url)
+ end
+
+ it "allows the local dev origin (default port omitted)" do
+ get "/api/v1/auth/google", params: { redirect_uri: "http://localhost:5180/auth/google/callback" }
+
+ expect(response).to have_http_status(:found)
+ end
+
+ it "rejects a redirect_uri whose origin is not allowlisted" do
+ get "/api/v1/auth/google", params: { redirect_uri: "https://evil.example.com/auth/google/callback" }
+
+ expect(SmbcloudAuthService).not_to have_received(:google_authorize_url)
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body["message"]).to eq("Unsupported redirect_uri.")
+ end
+
+ it "rejects a redirect_uri with the wrong callback path" do
+ get "/api/v1/auth/google", params: { redirect_uri: "https://code.sigit.si/somewhere-else" }
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it "rejects a GitHub callback path on the Google endpoint" do
+ get "/api/v1/auth/google", params: { redirect_uri: "https://code.sigit.si/auth/github/callback" }
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it "rejects a missing redirect_uri" do
+ get "/api/v1/auth/google"
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+end