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>
(cherry picked from commit d3ef2f927b6c1b7118ad15962937a18e39673d4a)
Seto Elkahfi committedJun 30, 2026 at 22:43 UTC413b0dc88cb4da245721d969ddeb174519d48b00
4 files changed+138
.env.example
+6
index fbe2a57..eb1c6d1 100644--- a/.env.example+++ b/.env.example@@ -22,6 +22,12 @@ SMBCLOUD_APP_SECRET=your-app-secret-here # Auth environment: "production" (default) or "dev" (local smbCloud Auth server) SMBCLOUD_ENVIRONMENT=production+# "Continue with GitHub" for the code.sigit.si SPA: origins allowed to receive+# the brokered access_token from GET /api/v1/auth/github (comma-separated). The+# request's redirect_uri must match one of these or the start is rejected.+# Defaults to the production SPA + the local vite dev server when unset.+CODE_CLOUD_ALLOWED_ORIGINS=https://code.sigit.si,http://localhost:5180+ # ----------------------------------------------------------------------------- # Onde Cloud (inference for the siGit clients)
app/controllers/api/v1/oauth/github_controller.rb
+75
new file mode 100644index 0000000..1c3e07c--- /dev/null+++ b/app/controllers/api/v1/oauth/github_controller.rb@@ -0,0 +1,75 @@+# frozen_string_literal: true++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++ 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}"+ 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
config/routes.rb
+5
index c277d9a..375e196 100644--- a/config/routes.rb+++ b/config/routes.rb@@ -103,6 +103,11 @@ 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).+ get "auth/github", to: "oauth/github#start" get "me", to: "me#show" delete "me", to: "me#destroy" get "repos", to: "repos#index"
spec/requests/api/v1/oauth/github_spec.rb
+52
new file mode 100644index 0000000..1ba2546--- /dev/null+++ b/spec/requests/api/v1/oauth/github_spec.rb@@ -0,0 +1,52 @@+# frozen_string_literal: true++require "rails_helper"++RSpec.describe "Api::V1::Oauth::Github", type: :request do+ let(:authorize_url) { "https://api.smbcloud.xyz/v1/client/oauth/github/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(:github_authorize_url).and_return(authorize_url)+ end++ describe "GET /api/v1/auth/github" do+ it "redirects to the smbCloud authorize URL for an allowlisted redirect_uri" do+ redirect_uri = "https://code.sigit.si/auth/github/callback"++ get "/api/v1/auth/github", params: { redirect_uri: redirect_uri }++ expect(SmbcloudAuthService).to have_received(:github_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/github", params: { redirect_uri: "http://localhost:5180/auth/github/callback" }++ expect(response).to have_http_status(:found)+ end++ it "rejects a redirect_uri whose origin is not allowlisted" do+ get "/api/v1/auth/github", params: { redirect_uri: "https://evil.example.com/auth/github/callback" }++ expect(SmbcloudAuthService).not_to have_received(:github_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/github", params: { redirect_uri: "https://code.sigit.si/somewhere-else" }++ expect(response).to have_http_status(:unprocessable_entity)+ end++ it "rejects a missing redirect_uri" do+ get "/api/v1/auth/github"++ expect(response).to have_http_status(:unprocessable_entity)+ end+ end+end