diff --git a/app/controllers/admin/dynamic_client_registration_controller.rb b/app/controllers/admin/dynamic_client_registration_controller.rb new file mode 100644 index 0000000..7db8cb2 --- /dev/null +++ b/app/controllers/admin/dynamic_client_registration_controller.rb @@ -0,0 +1,16 @@ +module Admin + # Toggles the RFC 7591 dynamic client registration window on/off at runtime. + class DynamicClientRegistrationController < BaseController + def update + enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled]) + Setting.set(Application::DCR_SETTING_KEY, enabled) + + notice = if enabled + "Dynamic client registration enabled. New clients can self-register — attach them to a group, then disable this again." + else + "Dynamic client registration disabled." + end + redirect_to admin_applications_path, notice: notice + end + end +end diff --git a/app/controllers/device_authorizations_controller.rb b/app/controllers/device_authorizations_controller.rb new file mode 100644 index 0000000..e1958e0 --- /dev/null +++ b/app/controllers/device_authorizations_controller.rb @@ -0,0 +1,88 @@ +# User-facing side of the OAuth 2.0 Device Authorization Grant (RFC 8628 §3.3). +# +# The CLI/agent sends the human here (GET /device) with the short user_code it +# was issued. This controller is authenticated, so an unauthenticated visitor is +# bounced through /signin (with their passkey) and returned here afterwards via +# session[:return_to_after_authenticating]. On POST /device the signed-in user +# approves or denies; approval attaches them to the device code and records +# consent so the token endpoint can mint tokens. +class DeviceAuthorizationsController < ApplicationController + # Browser form endpoint — keep CSRF protection on (do NOT skip it). + + # GET /device?user_code=WDJB-MJHT + def show + @user_code = params[:user_code].to_s + @device_code = OidcDeviceCode.find_by_user_code(@user_code) if @user_code.present? + + if @device_code.nil? + @state = @user_code.present? ? :not_found : :prompt + elsif @device_code.expired? + @state = :expired + elsif !@device_code.pending? + @state = :already_handled + else + @state = :confirm + @application = @device_code.application + @scopes = granted_scopes(@device_code) + end + + render :show + end + + # POST /device + def verify + @device_code = OidcDeviceCode.find_by_user_code(params[:user_code].to_s) + + if @device_code.nil? + @state = :not_found + return render :result + end + + if @device_code.expired? + @state = :expired + return render :result + end + + unless @device_code.pending? + @state = :already_handled + return render :result + end + + @application = @device_code.application + + if params[:deny].present? + @device_code.deny! + @state = :denied + return render :result + end + + # Enforce the same group-based access control as the OIDC authorize flow. + unless @application.user_allowed?(Current.user) + @state = :not_allowed + return render :result + end + + record_consent(@device_code, Current.user) + @device_code.approve!( + user: Current.user, + acr: Current.session.acr, + auth_time: Current.session.created_at.to_i + ) + @state = :approved + render :result + end + + private + + def granted_scopes(device_code) + device_code.scope.to_s.split & OidcController::SUPPORTED_SCOPES + end + + def record_consent(device_code, user) + consent = OidcUserConsent.find_or_initialize_by(user: user, application: device_code.application) + consent.scopes_granted = granted_scopes(device_code).join(" ") + consent.claims_requests = {} + consent.granted_at = Time.current + consent.save! + end +end diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 4625b0c..8ec4e51 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -3,12 +3,12 @@ class OidcController < ApplicationController # Discovery and JWKS endpoints are public # authorize is also unauthenticated to handle prompt=none and prompt=login specially - allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize] - # Machine-to-machine endpoints (token/revoke/userinfo) and pure redirect handlers - # (logout/authorize) legitimately skip CSRF. The consent endpoint is browser-facing - # and state-changing (it grants OAuth scopes), so it MUST keep CSRF protection — the - # consent form already embeds the token via form_with. - skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize] + allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :introspect, :userinfo, :logout, :authorize, :device_authorization] + # Machine-to-machine endpoints (token/revoke/introspect/userinfo/device_authorization) + # and pure redirect handlers (logout/authorize) legitimately skip CSRF. The consent + # endpoint is browser-facing and state-changing (it grants OAuth scopes), so it MUST + # keep CSRF protection — the consent form already embeds the token via form_with. + skip_before_action :verify_authenticity_token, only: [:token, :revoke, :introspect, :userinfo, :logout, :authorize, :device_authorization] # RFC 6749 §4.1.2.1: client_id and redirect_uri must be validated *before* any # other error can be reported via redirect. Failures here render a plain page. @@ -16,7 +16,7 @@ class OidcController < ApplicationController before_action :validate_redirect_uri, only: :authorize # Rate limiting to prevent brute force and abuse - rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> { + rate_limit to: 60, within: 1.minute, only: [:token, :revoke, :introspect, :device_authorization], with: -> { render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests } rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> { @@ -32,12 +32,14 @@ class OidcController < ApplicationController authorization_endpoint: "#{base_url}/oauth/authorize", token_endpoint: "#{base_url}/oauth/token", revocation_endpoint: "#{base_url}/oauth/revoke", + introspection_endpoint: "#{base_url}/oauth/introspect", userinfo_endpoint: "#{base_url}/oauth/userinfo", + device_authorization_endpoint: "#{base_url}/oauth/device_authorization", jwks_uri: "#{base_url}/.well-known/jwks.json", end_session_endpoint: "#{base_url}/logout", response_types_supported: ["code"], response_modes_supported: ["query"], - grant_types_supported: ["authorization_code", "refresh_token"], + grant_types_supported: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], subject_types_supported: ["pairwise"], id_token_signing_alg_values_supported: ["RS256"], scopes_supported: SUPPORTED_SCOPES, @@ -60,6 +62,11 @@ class OidcController < ApplicationController claims_parameter_supported: true } + # Only advertise dynamic client registration when it is enabled (RFC 7591). + if Application.dynamic_registration_enabled? + config[:registration_endpoint] = "#{base_url}/oauth/register" + end + render json: config end @@ -68,6 +75,55 @@ class OidcController < ApplicationController render json: OidcJwtService.jwks end + # POST /oauth/device_authorization + # RFC 8628 §3.1-3.2 — Device Authorization Request/Response. + # Public (PKCE) client presents its client_id and gets back a device_code the + # client polls with, plus a short user_code the human types on the /device page. + def device_authorization + client_id, _client_secret = extract_client_credentials + application = Application.find_by(client_id: client_id, app_type: "oidc") + + unless application&.active? + render json: {error: "invalid_client", error_description: "Unknown or inactive client"}, status: :unauthorized + return + end + + # Only accept scopes we support (mirrors the authorize endpoint). + requested_scope = (params[:scope].to_s.split & SUPPORTED_SCOPES).join(" ") + requested_scope = "openid" if requested_scope.blank? + + # PKCE is optional but recommended for device flow (RFC 8628 §5.5). If the + # client sends a challenge here it must send the verifier at the token endpoint. + code_challenge = params[:code_challenge].presence + code_challenge_method = params[:code_challenge_method].presence + + if code_challenge_method.present? && code_challenge_method != "S256" + render json: {error: "invalid_request", error_description: "Only S256 code_challenge_method is supported"}, status: :bad_request + return + end + + device_code = OidcDeviceCode.create!( + application: application, + scope: requested_scope, + nonce: params[:nonce].presence, + code_challenge: code_challenge, + code_challenge_method: code_challenge.present? ? (code_challenge_method || "S256") : nil + ) + + base_url = OidcJwtService.issuer_url + verification_uri = "#{base_url}/device" + + response.headers["Cache-Control"] = "no-store" + render json: { + device_code: device_code.plaintext_device_code, + user_code: device_code.user_code, + verification_uri: verification_uri, + verification_uri_complete: "#{verification_uri}?user_code=#{device_code.user_code}", + expires_in: (device_code.expires_at - Time.current).to_i, + interval: device_code.interval + } + end + # GET /oauth/authorize def authorize # @application and a validated redirect_uri are guaranteed by the before_actions. @@ -453,11 +509,141 @@ class OidcController < ApplicationController handle_authorization_code_grant when "refresh_token" handle_refresh_token_grant + when "urn:ietf:params:oauth:grant-type:device_code" + handle_device_code_grant else render json: {error: "unsupported_grant_type"}, status: :bad_request end end + # RFC 8628 §3.4-3.5 — the CLI/agent polls here with its device_code until the + # user approves on the /device page, then receives the standard token triple. + def handle_device_code_grant + client_id, client_secret = extract_client_credentials + + unless client_id + render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized + return + end + + application = Application.find_by(client_id: client_id) + unless application + render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized + return + end + + # Public clients authenticate with the device_code (+ optional PKCE); a + # confidential client using device flow must still present its secret. + if application.confidential_client? + unless client_secret.present? && application.authenticate_client_secret(client_secret) + render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized + return + end + end + + unless application.active? + render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden + return + end + + device_code = OidcDeviceCode.find_by_plaintext_device_code(params[:device_code]) + unless device_code && device_code.application_id == application.id + render json: {error: "invalid_grant", error_description: "Invalid device_code"}, status: :bad_request + return + end + + OidcDeviceCode.transaction do + # Lock so concurrent polls / a poll racing with approval can't double-issue. + device_code.lock! + + if device_code.expired? + render json: {error: "expired_token", error_description: "The device_code has expired"}, status: :bad_request + return + end + + if device_code.denied? + render json: {error: "access_denied", error_description: "The authorization request was denied"}, status: :bad_request + return + end + + if device_code.pending? + # Enforce the polling interval; too-frequent polls get slow_down, and the + # client is expected to add 5s to its interval (RFC 8628 §3.5). + if device_code.last_polled_at && (Time.current - device_code.last_polled_at) < device_code.interval + device_code.update!(interval: device_code.interval + 5, last_polled_at: Time.current) + render json: {error: "slow_down"}, status: :bad_request + else + device_code.update!(last_polled_at: Time.current) + render json: {error: "authorization_pending"}, status: :bad_request + end + return + end + + # Approved: mint tokens via the same path as the authorization code grant. + user = device_code.user + consent = OidcUserConsent.find_by(user: user, application: application) + unless consent + Rails.logger.error "OIDC Security: Device token requested without consent record (user: #{user&.id}, app: #{application.id})" + render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request + return + end + + # PKCE is optional for device flow: only enforced when the device + # authorization request supplied a code_challenge. + if device_code.uses_pkce? + pkce_result = validate_pkce(application, device_code, params[:code_verifier]) + unless pkce_result[:valid] + render json: {error: pkce_result[:error], error_description: pkce_result[:error_description]}, status: pkce_result[:status] + return + end + end + + granted_scope = device_code.scope + + access_token_record = OidcAccessToken.create!( + application: application, + user: user, + scope: granted_scope + ) + + refresh_token_record = OidcRefreshToken.create!( + application: application, + user: user, + oidc_access_token: access_token_record, + scope: granted_scope, + auth_time: device_code.auth_time, + acr: device_code.acr + ) + + id_token = OidcJwtService.generate_id_token( + user, + application, + consent: consent, + nonce: device_code.nonce, + access_token: access_token_record.plaintext_token, + auth_time: device_code.auth_time, + acr: device_code.acr, + scopes: granted_scope, + claims_requests: {} + ) + + # Single-use: destroy the code so an approved device_code can't be replayed. + device_code.destroy! + + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + + render json: { + access_token: access_token_record.plaintext_token, + token_type: "Bearer", + expires_in: application.access_token_ttl || 3600, + id_token: id_token, + refresh_token: refresh_token_record.token, + scope: granted_scope + } + end + end + def handle_authorization_code_grant # Get client credentials from Authorization header or params client_id, client_secret = extract_client_credentials @@ -868,6 +1054,59 @@ class OidcController < ApplicationController render json: claims end + # POST /oauth/introspect + # RFC 7662 - OAuth 2.0 Token Introspection. + # A resource server (e.g. c2a2) presents an opaque access token and its own + # client credentials; we reply whether the token is active and, as an extension, + # the user's groups so the resource server can authorize on group membership. + def introspect + # RFC 7662 §2.1: the caller (resource server) MUST authenticate. Only a + # registered confidential client may introspect. + caller_id, caller_secret = extract_client_credentials + caller = Application.find_by(client_id: caller_id) if caller_id.present? + + unless caller&.confidential_client? && caller.active? && + caller_secret.present? && caller.authenticate_client_secret(caller_secret) + render json: {error: "invalid_client", error_description: "Caller authentication failed"}, status: :unauthorized + return + end + + token_value = params[:token] + if token_value.blank? + render json: {error: "invalid_request", error_description: "token parameter is required"}, status: :bad_request + return + end + + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + + access_token = OidcAccessToken.find_by_token(token_value) + + # Inactive/unknown/expired/revoked tokens (or those for a disabled app) are + # reported as simply inactive per RFC 7662 §2.2 — never an error. + unless access_token&.active? && access_token.application&.active? && access_token.user + render json: {active: false} + return + end + + user = access_token.user + application = access_token.application + consent = OidcUserConsent.find_by(user: user, application: application) + + render json: { + active: true, + scope: access_token.scope, + client_id: application.client_id, + token_type: "Bearer", + exp: access_token.expires_at.to_i, + iat: access_token.created_at.to_i, + sub: consent&.sid || user.id.to_s, + aud: application.client_id, + username: user.email_address, + groups: user.groups.pluck(:name) + } + end + # POST /oauth/revoke # RFC 7009 - Token Revocation def revoke diff --git a/app/controllers/oidc_registration_controller.rb b/app/controllers/oidc_registration_controller.rb new file mode 100644 index 0000000..2aaaa26 --- /dev/null +++ b/app/controllers/oidc_registration_controller.rb @@ -0,0 +1,141 @@ +require "uri" + +# OAuth 2.0 Dynamic Client Registration (RFC 7591). +# +# Lets a client (e.g. an MCP connector such as Claude) register itself instead of +# being hand-created in the admin UI. Gated by a runtime toggle +# (Application.dynamic_registration_enabled?) that defaults off. Registered +# clients are default-deny — they have no allowed_groups until an admin attaches +# one — so an anonymous registration cannot reach any user's data on its own. +class OidcRegistrationController < ApplicationController + allow_unauthenticated_access only: [:create] + skip_before_action :verify_authenticity_token, only: [:create] + + rate_limit to: 10, within: 1.minute, only: [:create], with: -> { + render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests + } + + AUTH_METHODS = %w[none client_secret_basic client_secret_post].freeze + SUPPORTED_GRANT_TYPES = %w[authorization_code refresh_token].freeze + SUPPORTED_RESPONSE_TYPES = %w[code].freeze + + # POST /oauth/register + def create + unless Application.dynamic_registration_enabled? + render json: {error: "access_denied", error_description: "Dynamic client registration is disabled"}, status: :forbidden + return + end + + metadata = parse_body + if metadata == :invalid + return register_error("invalid_client_metadata", "Request body must be a valid JSON object") + end + + auth_method = metadata["token_endpoint_auth_method"].presence || "client_secret_basic" + unless AUTH_METHODS.include?(auth_method) + return register_error("invalid_client_metadata", "Unsupported token_endpoint_auth_method") + end + + grant_types = Array(metadata["grant_types"].presence || ["authorization_code"]) + if (grant_types - SUPPORTED_GRANT_TYPES).any? + return register_error("invalid_client_metadata", "Unsupported grant_types; only #{SUPPORTED_GRANT_TYPES.join(", ")} are allowed") + end + + response_types = Array(metadata["response_types"].presence || ["code"]) + if (response_types - SUPPORTED_RESPONSE_TYPES).any? + return register_error("invalid_client_metadata", "Unsupported response_types; only 'code' is allowed") + end + + redirect_uris = Array(metadata["redirect_uris"]).map(&:to_s).reject(&:blank?) + if redirect_uris.empty? + return register_error("invalid_redirect_uri", "At least one redirect_uri is required") + end + invalid = redirect_uris.reject { |uri| valid_redirect_uri?(uri) } + if invalid.any? + return register_error("invalid_redirect_uri", "Invalid redirect_uri: #{invalid.first}") + end + + public_client = (auth_method == "none") + client_name = metadata["client_name"].to_s.strip.presence || "Dynamically Registered Client" + + application = Application.new( + name: client_name, + slug: unique_slug(client_name), + app_type: "oidc", + active: true, + # MCP / OAuth 2.1 expect PKCE; public clients require it automatically. + require_pkce: true, + is_public_client: public_client, + redirect_uris: redirect_uris.to_json, + metadata: registration_metadata(metadata, auth_method).to_json + ) + + unless application.save + return register_error("invalid_client_metadata", application.errors.full_messages.join("; ")) + end + + body = { + client_id: application.client_id, + client_id_issued_at: application.created_at.to_i, + redirect_uris: redirect_uris, + token_endpoint_auth_method: auth_method, + grant_types: grant_types, + response_types: response_types, + client_name: client_name + } + body[:scope] = metadata["scope"] if metadata["scope"].present? + + # Return the plaintext secret exactly once, for confidential clients. + if application.confidential_client? + body[:client_secret] = application.client_secret + body[:client_secret_expires_at] = 0 # never expires + end + + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + render json: body, status: :created + end + + private + + def parse_body + parsed = JSON.parse(request.raw_post) + parsed.is_a?(Hash) ? parsed : :invalid + rescue JSON::ParserError + :invalid + end + + def register_error(error, description) + render json: {error: error, error_description: description}, status: :bad_request + end + + # RFC 7591 allows https everywhere and http only for loopback (native apps). + def valid_redirect_uri?(uri) + parsed = URI.parse(uri) + return false unless parsed.is_a?(URI::HTTP) # covers HTTP and HTTPS + return true if parsed.scheme == "https" + %w[localhost 127.0.0.1 ::1].include?(parsed.host) + rescue URI::InvalidURIError + false + end + + def unique_slug(name) + base = name.parameterize.presence || "client" + "#{base.first(40)}-#{SecureRandom.hex(6)}" + end + + # Preserve the descriptive metadata the client sent for later reference in the + # admin UI, without letting it drive access. + def registration_metadata(metadata, auth_method) + { + "dynamically_registered" => true, + "token_endpoint_auth_method" => auth_method, + "client_uri" => metadata["client_uri"], + "logo_uri" => metadata["logo_uri"], + "contacts" => metadata["contacts"], + "policy_uri" => metadata["policy_uri"], + "tos_uri" => metadata["tos_uri"], + "scope" => metadata["scope"] + }.compact + end +end diff --git a/app/models/application.rb b/app/models/application.rb index 3932d81..ba96aa9 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -103,6 +103,22 @@ class Application < ApplicationRecord app_type == "forward_auth" end + DCR_SETTING_KEY = "dynamic_client_registration".freeze + + # OAuth 2.0 Dynamic Client Registration (RFC 7591) is opt-in: it lets anyone + # anonymously create an OIDC client, so it is disabled by default. An admin + # toggles it at runtime (open the window, let a client self-register, attach it + # to a group, close the window again). Newly registered clients are still + # default-deny (no allowed_groups) until an admin grants access. + # + # The persisted Setting is authoritative once set; until then we fall back to + # the CLINCH_DCR_ENABLED env var (bootstrap/headless default, off if unset). + def self.dynamic_registration_enabled? + stored = Setting.boolean(DCR_SETTING_KEY) + return stored unless stored.nil? + ActiveModel::Type::Boolean.new.cast(ENV["CLINCH_DCR_ENABLED"]) + end + # Client type checks (for OIDC) def public_client? client_secret_digest.blank? diff --git a/app/models/oidc_device_code.rb b/app/models/oidc_device_code.rb new file mode 100644 index 0000000..1caa7ce --- /dev/null +++ b/app/models/oidc_device_code.rb @@ -0,0 +1,110 @@ +# OAuth 2.0 Device Authorization Grant code (RFC 8628). +# +# Mirrors OidcAuthorizationCode: the long device_code is opaque and stored as an +# HMAC, while the short user_code is stored in plaintext because the user types it +# back on the verification page. A record is created "pending" by the device +# authorization endpoint, moved to "approved" (with a user) or "denied" on the +# verification page, and consumed by the token endpoint once approved. +class OidcDeviceCode < ApplicationRecord + belongs_to :application + belongs_to :user, optional: true # nil until the request is approved + + # Alphabet for the user_code: uppercase letters + digits, minus visually + # ambiguous characters (0/O, 1/I, etc.) so it is easy to read and type. + USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".chars.freeze + USER_CODE_GROUP_SIZE = 4 + USER_CODE_GROUPS = 2 # e.g. "WDJB-MJHT" + + STATUSES = %w[pending approved denied].freeze + + attr_accessor :plaintext_device_code + + before_validation :generate_device_code, on: :create + before_validation :generate_user_code, on: :create + before_validation :set_expiry, on: :create + + validates :device_code_hmac, presence: true, uniqueness: true + validates :user_code, presence: true, uniqueness: true + validates :status, inclusion: {in: STATUSES} + validates :code_challenge_method, inclusion: {in: %w[S256], allow_nil: true} + validate :validate_code_challenge_format, if: -> { code_challenge.present? } + + scope :valid, -> { where(status: "pending").where("expires_at > ?", Time.current) } + scope :expired, -> { where("expires_at <= ?", Time.current) } + + # Find a device code by its plaintext device_code using HMAC verification. + def self.find_by_plaintext_device_code(plaintext_device_code) + return nil if plaintext_device_code.blank? + + find_by(device_code_hmac: compute_device_code_hmac(plaintext_device_code)) + end + + # Look up a device code by the human-typed user_code. Normalizes case and + # strips separators/whitespace so "wdjb-mjht" and "WDJB MJHT" both match. + def self.find_by_user_code(user_code) + return nil if user_code.blank? + + find_by(user_code: normalize_user_code(user_code)) + end + + def self.normalize_user_code(user_code) + user_code.to_s.upcase.gsub(/[^A-Z0-9]/, "") + end + + def self.compute_device_code_hmac(plaintext_device_code) + OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_device_code) + end + + def expired? + expires_at <= Time.current + end + + def pending? + status == "pending" + end + + def approved? + status == "approved" + end + + def denied? + status == "denied" + end + + def uses_pkce? + code_challenge.present? + end + + # Grant the request: attach the approving user and capture their auth context. + def approve!(user:, acr:, auth_time:) + update!(status: "approved", user: user, acr: acr, auth_time: auth_time) + end + + def deny! + update!(status: "denied") + end + + private + + def generate_device_code + self.plaintext_device_code ||= SecureRandom.urlsafe_base64(48) + self.device_code_hmac ||= self.class.compute_device_code_hmac(plaintext_device_code) + end + + def generate_user_code + self.user_code ||= USER_CODE_GROUPS.times.map do + USER_CODE_GROUP_SIZE.times.map { USER_CODE_ALPHABET.sample }.join + end.join + end + + def set_expiry + self.expires_at ||= 10.minutes.from_now + end + + def validate_code_challenge_format + # PKCE code challenge should be base64url-encoded, 43-128 characters. + unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/) + errors.add(:code_challenge, "must be 43-128 characters of base64url encoding") + end + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb new file mode 100644 index 0000000..0d154ed --- /dev/null +++ b/app/models/setting.rb @@ -0,0 +1,25 @@ +# Small persisted key/value store for runtime-togglable configuration that an +# admin can flip from the UI without a redeploy (e.g. the dynamic client +# registration window). Values are stored as strings; use the typed helpers. +class Setting < ApplicationRecord + validates :key, presence: true, uniqueness: true + + def self.get(key) + find_by(key: key.to_s)&.value + end + + def self.set(key, value) + record = find_or_initialize_by(key: key.to_s) + record.value = value.to_s + record.save! + value + end + + # Returns nil if the key has never been set, so callers can distinguish + # "unset" (fall back to a default) from an explicit false. + def self.boolean(key) + raw = get(key) + return nil if raw.nil? + ActiveModel::Type::Boolean.new.cast(raw) + end +end diff --git a/app/views/admin/applications/index.html.erb b/app/views/admin/applications/index.html.erb index ba2a383..36ce827 100644 --- a/app/views/admin/applications/index.html.erb +++ b/app/views/admin/applications/index.html.erb @@ -23,6 +23,30 @@ +<% dcr_on = Application.dynamic_registration_enabled? %> +
"> +
+

+ Dynamic client registration + "> + <%= dcr_on ? "On" : "Off" %> + +

+

+ <% if dcr_on %> + Clients can self-register via POST /oauth/register (RFC 7591). New clients get no access until you attach a group. Disable this once your client is connected. + <% else %> + New clients must be created here. Enable briefly to let a client (e.g. an MCP connector) register itself, then disable again. + <% end %> +

+
+ <%= button_to dcr_on ? "Disable" : "Enable", + admin_dynamic_client_registration_path, + method: :patch, + params: {enabled: !dcr_on}, + class: "shrink-0 rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm #{dcr_on ? "bg-amber-600 hover:bg-amber-500" : "bg-blue-600 hover:bg-blue-500"}" %> +
+
diff --git a/app/views/device_authorizations/result.html.erb b/app/views/device_authorizations/result.html.erb new file mode 100644 index 0000000..237a567 --- /dev/null +++ b/app/views/device_authorizations/result.html.erb @@ -0,0 +1,43 @@ +
+
+ <% case @state %> + <% when :approved %> + + + +

Device approved

+

+ <%= @application.name %> now has access to your account. You can return to your + terminal — it will continue automatically. You may close this tab. +

+ + <% when :denied %> + + + +

Request denied

+

+ No access was granted. You can close this tab. +

+ + <% when :not_allowed %> + + + +

Access not allowed

+

+ Your account isn't a member of a group permitted to use <%= @application.name %>. + Contact an administrator if you think this is a mistake. +

+ + <% else %> +

+ <%= @state == :expired ? "Code expired" : (@state == :already_handled ? "Code already used" : "Code not found") %> +

+

+ Start again from your tool to get a fresh code. +

+ <%= link_to "Enter a different code", device_verification_path, class: "mt-6 inline-block text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" %> + <% end %> +
+
diff --git a/app/views/device_authorizations/show.html.erb b/app/views/device_authorizations/show.html.erb new file mode 100644 index 0000000..154486e --- /dev/null +++ b/app/views/device_authorizations/show.html.erb @@ -0,0 +1,88 @@ +
+
+ <% case @state %> + <% when :confirm %> +
+ <% if @application.icon.attached? %> +
+ <%= app_icon_picture @application, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm" %> +
+ <% else %> +
+ <%= render "shared/app_monogram", name: @application.name, class: "h-20 w-20 rounded-xl shadow-sm" %> +
+ <% end %> +

Authorize device

+

+ <%= @application.name %> is requesting access to your account from a device or command line. +

+
+ +
+

Code shown on your device

+

<%= @device_code.user_code %>

+

Only approve if this matches the code your tool is showing.

+
+ + <% if @scopes.any? %> +
+

This will be able to:

+
    + <% scope_labels = { "openid" => "Verify your identity", "email" => "Access your email address (#{Current.user.email_address})", "profile" => "Access your profile information", "groups" => "Access your group memberships", "offline_access" => "Stay signed in (refresh access)" } %> + <% @scopes.each do |scope| %> +
  • + + + + <%= scope_labels[scope] || scope %> +
  • + <% end %> +
+
+ <% end %> + + <%= form_with url: device_verification_path, method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %> + <%= form.hidden_field :user_code, value: @device_code.user_code %> + <%= form.submit "Approve", + class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500" %> + <%= button_tag "Deny", + type: :submit, + name: :deny, + value: "1", + class: "w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500" %> + <% end %> + + <% when :prompt %> +
+

Enter device code

+

Type the code shown by your tool or command line.

+
+ <%= form_with url: device_verification_path, method: :get, class: "space-y-4", data: { turbo: false }, local: true do |form| %> + <%= form.text_field :user_code, + autofocus: true, + autocomplete: "off", + placeholder: "WDJB-MJHT", + class: "block w-full text-center font-mono text-xl tracking-widest uppercase rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500" %> + <%= form.submit "Continue", + class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500" %> + <% end %> + + <% else %> +
+

+ <%= @state == :expired ? "Code expired" : (@state == :already_handled ? "Code already used" : "Code not found") %> +

+

+ <% if @state == :expired %> + This device code has expired. Start again from your tool to get a fresh code. + <% elsif @state == :already_handled %> + This device code has already been approved or denied. Start again from your tool if you need a new one. + <% else %> + We couldn't find that code. Check the code your tool is showing and try again. + <% end %> +

+ <%= link_to "Enter a different code", device_verification_path, class: "mt-6 inline-block text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" %> +
+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 1fc6759..eca4d5c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,14 +25,25 @@ Rails.application.routes.draw do # OIDC (OpenID Connect) routes get "/.well-known/openid-configuration", to: "oidc#discovery" + # RFC 8414 OAuth 2.0 Authorization Server Metadata (alias of OIDC discovery; + # MCP clients look here). OIDC discovery is a superset of the RFC 8414 fields. + get "/.well-known/oauth-authorization-server", to: "oidc#discovery" get "/.well-known/jwks.json", to: "oidc#jwks" + # RFC 7591 Dynamic Client Registration + post "/oauth/register", to: "oidc_registration#create" match "/oauth/authorize", to: "oidc#authorize", via: [:get, :post] post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent post "/oauth/token", to: "oidc#token" post "/oauth/revoke", to: "oidc#revoke" + post "/oauth/introspect", to: "oidc#introspect" match "/oauth/userinfo", to: "oidc#userinfo", via: [:get, :post] get "/logout", to: "oidc#logout" + # OAuth 2.0 Device Authorization Grant (RFC 8628) + post "/oauth/device_authorization", to: "oidc#device_authorization" + get "/device", to: "device_authorizations#show", as: :device_verification + post "/device", to: "device_authorizations#verify" + # ForwardAuth / Trusted Header SSO namespace :api do get "/verify", to: "forward_auth#verify" @@ -96,6 +107,8 @@ Rails.application.routes.draw do end resources :groups get "access", to: "access_checks#new" + # Runtime toggle for the RFC 7591 dynamic client registration window. + resource :dynamic_client_registration, only: [:update], controller: "dynamic_client_registration" end # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) diff --git a/db/migrate/20260719000001_create_oidc_device_codes.rb b/db/migrate/20260719000001_create_oidc_device_codes.rb new file mode 100644 index 0000000..e008a46 --- /dev/null +++ b/db/migrate/20260719000001_create_oidc_device_codes.rb @@ -0,0 +1,43 @@ +class CreateOidcDeviceCodes < ActiveRecord::Migration[8.1] + def change + create_table :oidc_device_codes do |t| + t.references :application, null: false, foreign_key: true + # user_id is nullable: it stays blank while the code is pending and is + # filled in when the user approves the request on the verification page. + t.references :user, null: true, foreign_key: true + + # Opaque device_code, stored as an HMAC (never plaintext) — matches the + # OidcAuthorizationCode pattern. + t.string :device_code_hmac, null: false + # Short, human-typable code the user enters on the verification page. + # Stored in plaintext because the user reads it off one screen and types + # it into another; kept safe by short expiry + single use + rate limiting. + t.string :user_code, null: false + + t.string :status, null: false, default: "pending" # pending / approved / denied + + t.string :scope + t.string :nonce + + # PKCE (RFC 8628 permits and recommends PKCE for public clients). + t.string :code_challenge + t.string :code_challenge_method + + # Captured at approval time from the approving user's session. + t.string :acr + t.integer :auth_time + + t.datetime :expires_at, null: false + # Timestamp of the last token-endpoint poll, used to enforce the polling + # interval and emit slow_down (RFC 8628 §3.5). + t.datetime :last_polled_at + t.integer :interval, null: false, default: 5 + + t.timestamps + end + + add_index :oidc_device_codes, :device_code_hmac, unique: true + add_index :oidc_device_codes, :user_code, unique: true + add_index :oidc_device_codes, :expires_at + end +end diff --git a/db/migrate/20260719000002_create_settings.rb b/db/migrate/20260719000002_create_settings.rb new file mode 100644 index 0000000..ebef61f --- /dev/null +++ b/db/migrate/20260719000002_create_settings.rb @@ -0,0 +1,12 @@ +class CreateSettings < ActiveRecord::Migration[8.1] + def change + create_table :settings do |t| + t.string :key, null: false + t.string :value + + t.timestamps + end + + add_index :settings, :key, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 129980c..38dbe99 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_07_19_000002) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -161,6 +161,30 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id" end + create_table "oidc_device_codes", force: :cascade do |t| + t.string "acr" + t.integer "application_id", null: false + t.integer "auth_time" + t.string "code_challenge" + t.string "code_challenge_method" + t.datetime "created_at", null: false + t.string "device_code_hmac", null: false + t.datetime "expires_at", null: false + t.integer "interval", default: 5, null: false + t.datetime "last_polled_at" + t.string "nonce" + t.string "scope" + t.string "status", default: "pending", null: false + t.datetime "updated_at", null: false + t.string "user_code", null: false + t.integer "user_id" + t.index ["application_id"], name: "index_oidc_device_codes_on_application_id" + t.index ["device_code_hmac"], name: "index_oidc_device_codes_on_device_code_hmac", unique: true + t.index ["expires_at"], name: "index_oidc_device_codes_on_expires_at" + t.index ["user_code"], name: "index_oidc_device_codes_on_user_code", unique: true + t.index ["user_id"], name: "index_oidc_device_codes_on_user_id" + end + create_table "oidc_refresh_tokens", force: :cascade do |t| t.string "acr" t.integer "application_id", null: false @@ -218,6 +242,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do t.index ["user_id"], name: "index_sessions_on_user_id" end + create_table "settings", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "key", null: false + t.datetime "updated_at", null: false + t.string "value" + t.index ["key"], name: "index_settings_on_key", unique: true + end + create_table "user_groups", force: :cascade do |t| t.datetime "created_at", null: false t.integer "group_id", null: false @@ -286,6 +318,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do add_foreign_key "oidc_access_tokens", "users" add_foreign_key "oidc_authorization_codes", "applications" add_foreign_key "oidc_authorization_codes", "users" + add_foreign_key "oidc_device_codes", "applications" + add_foreign_key "oidc_device_codes", "users" add_foreign_key "oidc_refresh_tokens", "applications" add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens" add_foreign_key "oidc_refresh_tokens", "oidc_authorization_codes", on_delete: :nullify diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed..3c308d4 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -7,3 +7,45 @@ # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| # MovieGenre.find_or_create_by!(name: genre_name) # end + +# --- OAuth clients for CLI / agent access and token introspection --------------- +# +# These support the Device Authorization Grant (RFC 8628) and RFC 7662 token +# introspection. See docs/decisions/0002-device-authorization-grant.md. + +admins = Group.find_by(admin: true) + +# Public client (no secret, PKCE) used by CLIs and agents via the device flow. +# Ships with a well-known client_id so tools can hard-code it. +cli = Application.find_or_create_by!(client_id: "clinch-cli") do |app| + app.name = "Clinch CLI" + app.slug = "clinch-cli" + app.app_type = "oidc" + app.is_public_client = true + app.active = true +end + +# Grant the CLI to the admins group by default (device flow enforces +# Application#user_allowed?). Adjust to taste. +if admins && cli.allowed_groups.exclude?(admins) + cli.allowed_groups << admins + puts "Seeded 'clinch-cli' public client (allowed group: #{admins.name})." +end + +# Confidential client that resource servers (e.g. c2a2) use to authenticate to +# the introspection endpoint. The secret is only shown once, on creation. +unless Application.exists?(client_id: "c2a2-introspection") + secret = SecureRandom.urlsafe_base64(48) + Application.create!( + name: "c2a2 (introspection caller)", + slug: "c2a2-introspection", + client_id: "c2a2-introspection", + client_secret: secret, + app_type: "oidc", + active: true + ) + puts "Seeded 'c2a2-introspection' confidential client:" + puts " client_id: c2a2-introspection" + puts " client_secret: #{secret}" + puts " Store these in c2a2 now — the secret is hashed and cannot be recovered." +end diff --git a/docs/decisions/0001-opaque-vs-jwt-access-tokens.md b/docs/decisions/0001-opaque-vs-jwt-access-tokens.md new file mode 100644 index 0000000..06b76c0 --- /dev/null +++ b/docs/decisions/0001-opaque-vs-jwt-access-tokens.md @@ -0,0 +1,39 @@ +# 0001 — Opaque access tokens (not JWT); introspection for resource servers + +**Status:** Accepted · **Date:** 2026-07-19 + +## Decision + +Clinch issues **opaque** access and refresh tokens — random strings stored server-side +as SHA-256 HMACs (`OidcAccessToken` / `OidcRefreshToken`), not self-contained JWTs. The +**ID token stays a JWT** (RS256), because it is meant to be read by the client. Resource +servers that need to validate an access token call the **RFC 7662 introspection endpoint** +(`POST /oauth/introspect`), which also returns the user's `groups` for authorization. + +## Context + +There is no universal winner between opaque and JWT access tokens; it is an architecture +call: + +| | Opaque (reference) | JWT (self-contained) | +|---|---|---| +| Validation | Resource server calls back (introspect/userinfo) | Offline signature check against JWKS | +| **Revocation** | **Instant** — the AS holds the state | Valid until expiry unless a blocklist is added (which re-adds state) | +| Best when | One central IdP, few resource servers, revocation matters | Many resource servers, high throughput, offline verification needed | +| Token contents | Nothing leaks (just a handle) | Claims readable by any holder | + +Clinch is a single self-hosted IdP with a handful of relying parties. It already has +instant revocation, including refresh-token **family revocation** on reuse. That +revocation guarantee is a real security property for an IdP, and the introspection +callback cost is negligible at this scale (and cacheable by the resource server). + +## Consequences + +- Resource servers (e.g. c2a2) cannot verify tokens offline; they must call + `/oauth/introspect` (authenticated as a confidential client) and should briefly cache + positive results. +- Tokens can be revoked immediately (logout, admin action, reuse detection) and stop + working at the next introspection — a property JWT access tokens can't offer without + reintroducing server state. +- If we ever need many resource servers with zero-latency offline verification, revisit + with RFC 9068 (JWT access token profile) — accepting the loss of instant revocation. diff --git a/docs/decisions/0002-device-authorization-grant.md b/docs/decisions/0002-device-authorization-grant.md new file mode 100644 index 0000000..73eebd7 --- /dev/null +++ b/docs/decisions/0002-device-authorization-grant.md @@ -0,0 +1,50 @@ +# 0002 — CLI/agent auth via the Device Authorization Grant (RFC 8628) + +**Status:** Accepted · **Date:** 2026-07-19 + +## Decision + +CLIs and terminal agents (e.g. Claude) authenticate to Clinch-protected services as a +real user via the **OAuth 2.0 Device Authorization Grant (RFC 8628)** instead of static +API keys. The tool prints a short code and a URL; the user approves at `/device` with +their passkey; the tool polls the token endpoint and receives the standard +access + refresh + ID token triple. + +## Context + +We wanted CLIs — and especially headless agents — to authenticate as a real user rather +than carry a long-lived API key. The two mainstream options: + +- **Device flow (RFC 8628):** tool prints a code, human approves on any device, tool + polls. Needs only "print text" + "poll HTTP". +- **Auth code + PKCE with a loopback (`127.0.0.1`) redirect (RFC 8252):** tool opens a + browser and catches a local redirect. + +Agents are often sandboxed or run on a remote box where opening a browser and receiving a +loopback redirect is unreliable. Device flow needs neither, which is exactly why it fits +CLIs and agents. The human approves wherever their passkey lives. + +## Implementation notes + +- `OidcDeviceCode` mirrors `OidcAuthorizationCode`: opaque `device_code` stored as an + HMAC, short plaintext `user_code`, nullable `user` until approval, `status` + (pending/approved/denied), PKCE columns, and `interval`/`last_polled_at` for + `slow_down` enforcement. +- Endpoints: `POST /oauth/device_authorization`, the + `urn:ietf:params:oauth:grant-type:device_code` branch of `POST /oauth/token`, and the + authenticated verification page `GET/POST /device`. +- Token issuance reuses the authorization-code path (`OidcAccessToken` + + `OidcRefreshToken` + `OidcJwtService`). Access control reuses + `Application#user_allowed?`, so approval is gated by group membership. +- PKCE is **optional** for device flow (RFC 8628 §5.5): enforced only when the device + authorization request supplied a `code_challenge`. The `device_code` itself is a + high-entropy secret delivered directly to the client over TLS. +- A well-known public client `clinch-cli` (no secret, PKCE) is seeded for tools to use. + +## Consequences + +- No static API keys for user-context CLI/agent access; tokens are revocable and expire. +- Resource servers validate the resulting opaque access tokens via introspection — see + [0001](0001-opaque-vs-jwt-access-tokens.md). +- Same foundation (public clients + PKCE + introspection) supports future MCP connector + support, whose main additional piece would be Dynamic Client Registration (RFC 7591). diff --git a/docs/decisions/0003-dynamic-client-registration.md b/docs/decisions/0003-dynamic-client-registration.md new file mode 100644 index 0000000..b4fd298 --- /dev/null +++ b/docs/decisions/0003-dynamic-client-registration.md @@ -0,0 +1,55 @@ +# 0003 — Dynamic Client Registration (RFC 7591), runtime-gated + +**Status:** Accepted · **Date:** 2026-07-19 + +## Decision + +Clinch supports **OAuth 2.0 Dynamic Client Registration (RFC 7591)** at +`POST /oauth/register`, so clients (notably MCP connectors like Claude) can register +themselves instead of being hand-created. It is **off by default** and toggled at runtime +by an admin from the Applications page. A newly registered client is **default-deny**: it +has no `allowed_groups` until an admin attaches one. + +We also serve the **RFC 8414** metadata alias at +`/.well-known/oauth-authorization-server` (the OIDC discovery document is a superset), and +advertise `registration_endpoint` only while registration is enabled. + +## Context + +MCP connectors expect to self-register via anonymous DCR rather than being pre-provisioned. +But open registration is a real risk: anyone could register a legitimate-looking client and +attempt **consent phishing** — luring a user to approve it, then holding a token that acts +as that user against any resource server that trusts clinch tokens (via introspection, see +[0001](0001-opaque-vs-jwt-access-tokens.md)). + +Two controls make this safe: + +1. **Runtime window, not always-on.** DCR is a toggle (persisted `Setting`, admin UI), so + the operator opens it briefly, lets the client register, attaches a group, and closes it + again. The `CLINCH_DCR_ENABLED` env var is only a bootstrap default when the setting is + unset. Default is off. +2. **Default-deny for new clients.** Clinch's authorize flow already gates on + `Application#user_allowed?` (group membership), evaluated *before* the consent screen + renders. A group-less registered client therefore can't show any user an approve button — + the consent-phishing path dead-ends until an admin explicitly grants a group. ForwardAuth + services are gated by the user's session cookie, not client tokens, so DCR doesn't widen + that surface at all. + +## Implementation notes + +- `OidcRegistrationController#create`: validates `token_endpoint_auth_method` + (none/client_secret_basic/client_secret_post), `grant_types` + (authorization_code/refresh_token), `response_types` (code), and `redirect_uris` + (https anywhere; http only for loopback). Creates a public or confidential `Application` + with `require_pkce: true`, returns the RFC 7591 response (client_secret once, for + confidential clients). +- `Setting` is a small key/value store; `Application.dynamic_registration_enabled?` reads it + with the env var as fallback. Admin toggle: `Admin::DynamicClientRegistrationController`. + +## Consequences + +- MCP connectors can self-register when the window is open, then operate normally once an + admin grants a group. +- No always-on anonymous registration surface; the risky window is short and operator-driven. +- Remaining MCP pieces (resource indicators RFC 8707, protected-resource metadata RFC 9728 on + the resource server) are separate, smaller follow-ups. diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..cfd92c2 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,12 @@ +# Architecture Decision Records + +Short, dated records of non-obvious technical decisions in Clinch. They live in the +repo (rather than a wiki) so they version with the code and travel with a checkout. + +Each file is one decision. Newest decisions get the next number. + +| # | Decision | +|---|----------| +| [0001](0001-opaque-vs-jwt-access-tokens.md) | Access tokens are opaque (not JWT); resource servers use introspection | +| [0002](0002-device-authorization-grant.md) | CLI/agent auth uses the OAuth 2.0 Device Authorization Grant (RFC 8628) | +| [0003](0003-dynamic-client-registration.md) | Dynamic Client Registration (RFC 7591), runtime-gated + default-deny | diff --git a/test/controllers/oidc_device_flow_controller_test.rb b/test/controllers/oidc_device_flow_controller_test.rb new file mode 100644 index 0000000..a79dfef --- /dev/null +++ b/test/controllers/oidc_device_flow_controller_test.rb @@ -0,0 +1,231 @@ +require "test_helper" + +class OidcDeviceFlowControllerTest < ActionDispatch::IntegrationTest + DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze + + def setup + @group = Group.create!(name: "device-flow-testers", description: "test") + @user = User.create!(email_address: "device_flow@example.com", password: "password123") + @user.groups << @group + + @cli = Application.create!( + name: "Device Flow CLI", + slug: "device-flow-cli", + app_type: "oidc", + is_public_client: true, + active: true + ) + @cli.allowed_groups << @group + + @resource_secret = "resource-server-secret-value-1234567890" + @resource = Application.create!( + name: "Device Flow Resource Server", + slug: "device-flow-rs", + app_type: "oidc", + client_secret: @resource_secret, + active: true + ) + end + + def teardown + Current.session = nil + [@cli, @resource].each do |app| + OidcRefreshToken.where(application: app).delete_all + OidcAccessToken.where(application: app).delete_all + OidcDeviceCode.where(application: app).delete_all + OidcUserConsent.where(application: app).delete_all + end + end + + # --- Discovery ------------------------------------------------------------- + + test "discovery advertises the device grant and new endpoints" do + get "/.well-known/openid-configuration" + assert_response :success + config = JSON.parse(@response.body) + + assert_includes config["grant_types_supported"], DEVICE_GRANT + assert config["device_authorization_endpoint"].end_with?("/oauth/device_authorization") + assert config["introspection_endpoint"].end_with?("/oauth/introspect") + end + + # --- Device authorization endpoint ----------------------------------------- + + test "device_authorization issues a device_code and user_code" do + post "/oauth/device_authorization", params: {client_id: @cli.client_id, scope: "openid groups"} + assert_response :success + body = JSON.parse(@response.body) + + assert body["device_code"].present? + assert_match(/\A[A-HJ-NP-Z2-9]{8}\z/, body["user_code"]) + assert body["verification_uri"].end_with?("/device") + assert body["verification_uri_complete"].include?("user_code=#{body["user_code"]}") + assert_equal 5, body["interval"] + assert body["expires_in"].positive? + end + + test "device_authorization rejects an unknown client" do + post "/oauth/device_authorization", params: {client_id: "does-not-exist"} + assert_response :unauthorized + assert_equal "invalid_client", JSON.parse(@response.body)["error"] + end + + # --- Token endpoint device_code grant -------------------------------------- + + test "token endpoint returns authorization_pending while pending" do + dc = OidcDeviceCode.create!(application: @cli, scope: "openid") + poll(dc) + assert_response :bad_request + assert_equal "authorization_pending", JSON.parse(@response.body)["error"] + end + + test "token endpoint returns slow_down when polled faster than the interval" do + dc = OidcDeviceCode.create!(application: @cli, scope: "openid") + poll(dc) # first poll records last_polled_at + poll(dc) # immediate second poll is too fast + assert_response :bad_request + assert_equal "slow_down", JSON.parse(@response.body)["error"] + end + + test "token endpoint returns expired_token for an expired code" do + dc = OidcDeviceCode.create!(application: @cli, scope: "openid", expires_at: 1.minute.ago) + poll(dc) + assert_response :bad_request + assert_equal "expired_token", JSON.parse(@response.body)["error"] + end + + test "token endpoint returns access_denied when the user denied" do + dc = OidcDeviceCode.create!(application: @cli, scope: "openid") + dc.deny! + poll(dc) + assert_response :bad_request + assert_equal "access_denied", JSON.parse(@response.body)["error"] + end + + test "token endpoint issues tokens once approved, then the code is single-use" do + OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid groups", granted_at: Time.current) + dc = OidcDeviceCode.create!(application: @cli, scope: "openid groups") + dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i) + + poll(dc) + assert_response :success + body = JSON.parse(@response.body) + assert body["access_token"].present? + assert body["refresh_token"].present? + assert body["id_token"].present? + assert_equal "Bearer", body["token_type"] + assert_equal "openid groups", body["scope"] + + # Replaying the (now consumed) device_code fails. + poll(dc) + assert_response :bad_request + assert_equal "invalid_grant", JSON.parse(@response.body)["error"] + end + + # --- Verification page ----------------------------------------------------- + + test "verification page shows the approval prompt for a signed-in allowed user" do + sign_in_as(@user) + dc = OidcDeviceCode.create!(application: @cli, scope: "openid groups") + + get "/device", params: {user_code: dc.user_code} + assert_response :success + assert_match(/Approve/, @response.body) + assert_match(dc.user_code, @response.body) + end + + test "approving records consent and approves the device code" do + sign_in_as(@user) + dc = OidcDeviceCode.create!(application: @cli, scope: "openid groups") + + post "/device", params: {user_code: dc.user_code} + assert_response :success + assert_match(/approved/i, @response.body) + + dc.reload + assert dc.approved? + assert_equal @user, dc.user + assert OidcUserConsent.exists?(user: @user, application: @cli) + end + + test "denying marks the device code denied" do + sign_in_as(@user) + dc = OidcDeviceCode.create!(application: @cli, scope: "openid") + + post "/device", params: {user_code: dc.user_code, deny: "1"} + assert_response :success + dc.reload + assert dc.denied? + end + + test "a user without access cannot approve" do + outsider = User.create!(email_address: "outsider@example.com", password: "password123") + sign_in_as(outsider) + dc = OidcDeviceCode.create!(application: @cli, scope: "openid") + + post "/device", params: {user_code: dc.user_code} + assert_response :success + assert_match(/not allowed/i, @response.body) + dc.reload + assert dc.pending?, "device code must stay pending when approval is refused" + end + + test "verification page requires authentication" do + dc = OidcDeviceCode.create!(application: @cli, scope: "openid") + get "/device", params: {user_code: dc.user_code} + assert_redirected_to signin_path + end + + # --- Introspection --------------------------------------------------------- + + test "introspection reports an active token with groups" do + token = OidcAccessToken.create!(application: @cli, user: @user, scope: "openid groups") + + post "/oauth/introspect", params: { + token: token.plaintext_token, + client_id: @resource.client_id, + client_secret: @resource_secret + } + assert_response :success + body = JSON.parse(@response.body) + + assert_equal true, body["active"] + assert_equal @cli.client_id, body["client_id"] + assert_includes body["groups"], @group.name + assert body["sub"].present? + end + + test "introspection reports inactive for a revoked token" do + token = OidcAccessToken.create!(application: @cli, user: @user, scope: "openid") + token.revoke! + + post "/oauth/introspect", params: { + token: token.plaintext_token, + client_id: @resource.client_id, + client_secret: @resource_secret + } + assert_response :success + assert_equal false, JSON.parse(@response.body)["active"] + end + + test "introspection requires valid caller credentials" do + token = OidcAccessToken.create!(application: @cli, user: @user, scope: "openid") + + post "/oauth/introspect", params: { + token: token.plaintext_token, + client_id: @resource.client_id, + client_secret: "wrong-secret" + } + assert_response :unauthorized + end + + private + + def poll(device_code) + post "/oauth/token", params: { + grant_type: DEVICE_GRANT, + device_code: device_code.plaintext_device_code, + client_id: @cli.client_id + } + end +end diff --git a/test/controllers/oidc_registration_controller_test.rb b/test/controllers/oidc_registration_controller_test.rb new file mode 100644 index 0000000..a793410 --- /dev/null +++ b/test/controllers/oidc_registration_controller_test.rb @@ -0,0 +1,137 @@ +require "test_helper" + +class OidcRegistrationControllerTest < ActionDispatch::IntegrationTest + JSON_HEADERS = {"Content-Type" => "application/json"}.freeze + + def teardown + Setting.where(key: Application::DCR_SETTING_KEY).delete_all + Application.where("slug LIKE ?", "%-%").where(metadata: nil).delete_all + Application.where("metadata LIKE ?", "%dynamically_registered%").destroy_all + end + + def enable_dcr + Setting.set(Application::DCR_SETTING_KEY, true) + end + + def register(body) + post "/oauth/register", params: body.to_json, headers: JSON_HEADERS + end + + test "registration is disabled by default" do + register(redirect_uris: ["https://client.example.com/cb"], token_endpoint_auth_method: "none") + assert_response :forbidden + assert_equal "access_denied", JSON.parse(@response.body)["error"] + end + + test "registers a public client and returns no secret" do + enable_dcr + register( + redirect_uris: ["https://client.example.com/cb"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + client_name: "My MCP Connector" + ) + + assert_response :created + body = JSON.parse(@response.body) + assert body["client_id"].present? + assert_not body.key?("client_secret") + assert_equal ["https://client.example.com/cb"], body["redirect_uris"] + assert_equal "none", body["token_endpoint_auth_method"] + + app = Application.find_by(client_id: body["client_id"]) + assert app.public_client? + assert app.require_pkce? + assert_empty app.allowed_groups, "a freshly registered client must be default-deny" + end + + test "registers a confidential client and returns a secret once" do + enable_dcr + register( + redirect_uris: ["https://client.example.com/cb"], + token_endpoint_auth_method: "client_secret_basic" + ) + + assert_response :created + body = JSON.parse(@response.body) + assert body["client_secret"].present? + assert_equal 0, body["client_secret_expires_at"] + + app = Application.find_by(client_id: body["client_id"]) + assert app.confidential_client? + assert app.authenticate_client_secret(body["client_secret"]) + end + + test "requires at least one redirect_uri" do + enable_dcr + register(token_endpoint_auth_method: "none") + assert_response :bad_request + assert_equal "invalid_redirect_uri", JSON.parse(@response.body)["error"] + end + + test "rejects non-loopback http redirect_uris" do + enable_dcr + register(redirect_uris: ["http://evil.example.com/cb"], token_endpoint_auth_method: "none") + assert_response :bad_request + assert_equal "invalid_redirect_uri", JSON.parse(@response.body)["error"] + end + + test "allows http redirect_uris for loopback" do + enable_dcr + register(redirect_uris: ["http://localhost:8123/cb"], token_endpoint_auth_method: "none") + assert_response :created + end + + test "rejects unsupported grant types" do + enable_dcr + register(redirect_uris: ["https://client.example.com/cb"], grant_types: ["client_credentials"]) + assert_response :bad_request + assert_equal "invalid_client_metadata", JSON.parse(@response.body)["error"] + end + + test "rejects a non-JSON body" do + enable_dcr + post "/oauth/register", params: "not json", headers: JSON_HEADERS + assert_response :bad_request + assert_equal "invalid_client_metadata", JSON.parse(@response.body)["error"] + end + + # --- Discovery advertisement ---------------------------------------------- + + test "discovery advertises registration_endpoint only when enabled" do + get "/.well-known/openid-configuration" + assert_not JSON.parse(@response.body).key?("registration_endpoint") + + enable_dcr + get "/.well-known/openid-configuration" + assert JSON.parse(@response.body)["registration_endpoint"].end_with?("/oauth/register") + end + + test "RFC 8414 metadata alias mirrors OIDC discovery" do + get "/.well-known/oauth-authorization-server" + assert_response :success + config = JSON.parse(@response.body) + assert config["token_endpoint"].end_with?("/oauth/token") + assert config["authorization_endpoint"].end_with?("/oauth/authorize") + end + + # --- Admin runtime toggle -------------------------------------------------- + + test "admin can toggle the registration window at runtime" do + sign_in_as(users(:alice)) # alice is in the admin group + + patch "/admin/dynamic_client_registration", params: {enabled: "true"} + assert_redirected_to admin_applications_path + assert Application.dynamic_registration_enabled? + + patch "/admin/dynamic_client_registration", params: {enabled: "false"} + assert_not Application.dynamic_registration_enabled? + end + + test "non-admins cannot toggle registration" do + sign_in_as(users(:one)) # not an admin + patch "/admin/dynamic_client_registration", params: {enabled: "true"} + assert_redirected_to root_path + assert_not Application.dynamic_registration_enabled? + end +end diff --git a/test/models/oidc_device_code_test.rb b/test/models/oidc_device_code_test.rb new file mode 100644 index 0000000..5edf5cf --- /dev/null +++ b/test/models/oidc_device_code_test.rb @@ -0,0 +1,84 @@ +require "test_helper" + +class OidcDeviceCodeTest < ActiveSupport::TestCase + def setup + @application = Application.create!( + name: "Device Code Model Test", + slug: "device-code-model-test", + app_type: "oidc", + is_public_client: true, + active: true + ) + @user = User.create!(email_address: "device_model@example.com", password: "password123") + end + + test "generates an opaque device_code stored as HMAC and looked up by plaintext" do + dc = OidcDeviceCode.create!(application: @application) + + assert dc.plaintext_device_code.present? + assert dc.device_code_hmac.present? + assert_not_equal dc.plaintext_device_code, dc.device_code_hmac + assert_equal dc, OidcDeviceCode.find_by_plaintext_device_code(dc.plaintext_device_code) + assert_nil OidcDeviceCode.find_by_plaintext_device_code("wrong") + end + + test "generates a short user_code from the unambiguous alphabet" do + dc = OidcDeviceCode.create!(application: @application) + + assert_equal 8, dc.user_code.length + # No visually ambiguous characters (0/O, 1/I) and only the allowed alphabet. + assert_match(/\A[A-HJ-NP-Z2-9]{8}\z/, dc.user_code) + end + + test "find_by_user_code normalizes case, hyphens, and whitespace" do + dc = OidcDeviceCode.create!(application: @application) + formatted = "#{dc.user_code[0, 4]}-#{dc.user_code[4, 4]}".downcase + + assert_equal dc, OidcDeviceCode.find_by_user_code(formatted) + assert_equal dc, OidcDeviceCode.find_by_user_code(" #{dc.user_code} ") + assert_nil OidcDeviceCode.find_by_user_code("nope") + end + + test "user_code is unique" do + dc = OidcDeviceCode.create!(application: @application) + dup = OidcDeviceCode.new(application: @application, user_code: dc.user_code) + + assert_not dup.valid? + assert_includes dup.errors[:user_code], "has already been taken" + end + + test "starts pending and approve! attaches the user and auth context" do + dc = OidcDeviceCode.create!(application: @application) + assert dc.pending? + + dc.approve!(user: @user, acr: "1", auth_time: 1_700_000_000) + + assert dc.approved? + assert_equal @user, dc.user + assert_equal "1", dc.acr + assert_equal 1_700_000_000, dc.auth_time + end + + test "deny! marks the code denied" do + dc = OidcDeviceCode.create!(application: @application) + dc.deny! + assert dc.denied? + end + + test "expired? reflects expires_at" do + assert OidcDeviceCode.create!(application: @application, expires_at: 1.minute.ago).expired? + assert_not OidcDeviceCode.create!(application: @application).expired? + end + + test "uses_pkce? and rejects malformed code_challenge" do + assert_not OidcDeviceCode.create!(application: @application).uses_pkce? + + valid_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + dc = OidcDeviceCode.create!(application: @application, code_challenge: valid_challenge, code_challenge_method: "S256") + assert dc.uses_pkce? + + bad = OidcDeviceCode.new(application: @application, code_challenge: "too-short") + assert_not bad.valid? + assert_includes bad.errors[:code_challenge], "must be 43-128 characters of base64url encoding" + end +end