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 %> +
++ <%= @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 %> + ++ No access was granted. You can close this tab. +
+ + <% when :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 %> ++ 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 %> ++ <%= @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.
+Type the code shown by your tool or command line.
++ <% 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" %> +