main
rb 78 lines 3.67 KB
Raw
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