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
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 100644 index 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 15bb910..60e4f60 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,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 100644 index 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