Add brokered GitHub sign-in start for the code.sigit.si SPA

The web flow (Oauth::GithubController) keeps GitHub sign-in on sigit.si and sets a cookie session. The code.sigit.si SPA is client-only: it can't hold the smbCloud app secret and needs a token back, not a cookie. Add the server half it's missing. GET /api/v1/auth/github injects the app secret server-side and 302s to smbCloud's authorize URL, carrying the SPA's callback as redirect_uri; smbCloud brokers GitHub and bounces back to the SPA with ?access_token=. That token is the same bearer Api::BaseController#authenticate_token! already validates, so no extra exchange is needed. redirect_uri is checked against an allowlist (CODE_CLOUD_ALLOWED_ORIGINS) so the token can't be redirected off-origin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 22:43 UTC d3ef2f927b6c1b7118ad15962937a18e39673d4a
4 files changed +138
.env.example
+6
@@ -22,6 +22,12 @@ SMBCLOUD_APP_SECRET=your-app-secret-here
22 # Auth environment: "production" (default) or "dev" (local smbCloud Auth server)
23 SMBCLOUD_ENVIRONMENT=production
24
25 +# "Continue with GitHub" for the code.sigit.si SPA: origins allowed to receive
26 +# the brokered access_token from GET /api/v1/auth/github (comma-separated). The
27 +# request's redirect_uri must match one of these or the start is rejected.
28 +# Defaults to the production SPA + the local vite dev server when unset.
29 +CODE_CLOUD_ALLOWED_ORIGINS=https://code.sigit.si,http://localhost:5180
30 +
31
32 # -----------------------------------------------------------------------------
33 # Onde Cloud (inference for the siGit clients)
app/controllers/api/v1/oauth/github_controller.rb new
+75
@@ -0,0 +1,75 @@
1 +# frozen_string_literal: true
2 +
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 +
40 + private
41 +
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}"
63 + end
64 +
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?)
71 + end
72 + end
73 + end
74 + end
75 +end
config/routes.rb
+5
@@ -86,6 +86,11 @@ Rails.application.routes.draw do
86 post "auth/sign_up", to: "registrations#create"
87 post "auth/confirmation/resend", to: "confirmations#create"
88 post "auth/password/reset", to: "passwords#create"
89 + # "Continue with GitHub" for browser SPA clients (code.sigit.si). A
90 + # server-side redirect that brokers the smbCloud GitHub flow so the app
91 + # secret never reaches the client; smbCloud bounces back to the SPA's
92 + # redirect_uri with an access_token (the same bearer the API expects).
93 + get "auth/github", to: "oauth/github#start"
94 get "me", to: "me#show"
95 delete "me", to: "me#destroy"
96 get "repos", to: "repos#index"
spec/requests/api/v1/oauth/github_spec.rb new
+52
@@ -0,0 +1,52 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +RSpec.describe "Api::V1::Oauth::Github", type: :request do
6 + let(:authorize_url) { "https://api.smbcloud.xyz/v1/client/oauth/github/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(:github_authorize_url).and_return(authorize_url)
14 + end
15 +
16 + describe "GET /api/v1/auth/github" do
17 + it "redirects to the smbCloud authorize URL for an allowlisted redirect_uri" do
18 + redirect_uri = "https://code.sigit.si/auth/github/callback"
19 +
20 + get "/api/v1/auth/github", params: { redirect_uri: redirect_uri }
21 +
22 + expect(SmbcloudAuthService).to have_received(:github_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/github", params: { redirect_uri: "http://localhost:5180/auth/github/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/github", params: { redirect_uri: "https://evil.example.com/auth/github/callback" }
34 +
35 + expect(SmbcloudAuthService).not_to have_received(:github_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/github", 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 missing redirect_uri" do
47 + get "/api/v1/auth/github"
48 +
49 + expect(response).to have_http_status(:unprocessable_entity)
50 + end
51 + end
52 +end