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