| 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 |