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