From 01909439587dfa6323865f32f3fae85ab0a60f8e Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 3 Oct 2026 11:46:54 -0400 Subject: [PATCH 1/3] test(levelcode): pin which addresses count as the editor's callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec only. Two controllers decide whether a sign-in may hand its one-time code to a redirect_uri: Levelcode::WebController for the /ai flows, and the API's AuthController for /auth/login. Each keeps its own copy of the rule — scheme on a short list, host and path exact — and almost none of it was under test. Run against the code as it stands, every one of these passed the suite: the pre-rename scheme (atom-plus-plus) dropped web, api another scheme added to the list web, api the host no longer compared web, api the path no longer compared web, api The one refusal the suite did check was an https address, which fails on the scheme before the host or the path is looked at. Thirteen examples, one per address, at both gates: the shipped editor and a pre-rename build get the code with ?windowId kept; a scheme not on the list, the right scheme on another host, the right host on another path, https, and an address with no host get nothing. With them, each of the eight mutations above fails exactly the example that names it. One of them records something worth knowing before the list changes: at /ai/auth/verify an editor-shaped address on a scheme the server does not take is not refused — it is simply not an editor sign-in. The browser is signed in to the web account and the editor that asked hears nothing. --- spec/requests/api/levelcode/v1/auth_spec.rb | 32 +++++++++++++ spec/requests/levelcode/web_spec.rb | 51 +++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/spec/requests/api/levelcode/v1/auth_spec.rb b/spec/requests/api/levelcode/v1/auth_spec.rb index a59080a4..0edf8903 100644 --- a/spec/requests/api/levelcode/v1/auth_spec.rb +++ b/spec/requests/api/levelcode/v1/auth_spec.rb @@ -79,6 +79,38 @@ def json expect(json.dig('error', 'code')).to eq('invalid_credentials') end + # WHICH addresses are the editor's, at this controller's own gate (it keeps its own copy of the + # rule Levelcode::WebController has). Anything else falls back to JSON: no redirect, no code. + { + 'levelcode://levelcode.levelcode-ai/auth/callback' => 'the shipped editor', + 'atom-plus-plus://levelcode.levelcode-ai/auth/callback' => 'a build from before the rename' + }.each do |address, whose| + it "302s the code to #{whose} — #{address}" do + post '/api/levelcode/v1/auth/login', + params: { email: user.email, password: password, redirect_uri: "#{address}?windowId=2", code_challenge: 'chal' } + + expect(response).to have_http_status(:found) + location = response.headers['Location'] + expect(location).to start_with("#{address}?windowId=2&code=") + expect(Rack::Utils.parse_query(URI.parse(location).query)['code']).to be_present + end + end + + { + 'levelcode-dev://levelcode.levelcode-ai/auth/callback' => 'a scheme that is not on the list', + 'levelcode://evil.example/auth/callback' => 'the right scheme on another host', + 'levelcode://levelcode.levelcode-ai/elsewhere' => 'the right host on another path' + }.each do |address, what| + it "does not redirect to #{what} — #{address}" do + post '/api/levelcode/v1/auth/login', + params: { email: user.email, password: password, redirect_uri: address, code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(response.headers['Location']).to be_nil + expect(json['access']).to be_present + end + end + it 'refuses a non-https/non-editor redirect_uri (no open redirect)' do post '/api/levelcode/v1/auth/login', params: { email: user.email, password: password, redirect_uri: 'http://evil.example.com' } diff --git a/spec/requests/levelcode/web_spec.rb b/spec/requests/levelcode/web_spec.rb index 87a0590d..a3be0c4f 100644 --- a/spec/requests/levelcode/web_spec.rb +++ b/spec/requests/levelcode/web_spec.rb @@ -224,6 +224,21 @@ def json = JSON.parse(response.body) expect(json).to eq('redirect' => '/ai/account') end + # An address that is not the editor's is not refused here — it is not an editor sign-in at all. + # The browser is signed in to the web account and sent to it, and the editor that asked hears + # nothing. That is what a developer sees when their editor's scheme is not one this server takes. + it 'treats an editor-shaped address on a scheme it does not take as a web sign-in' do + allow(Levelcode::EmailCode).to receive(:verify).and_return(true) + expect(Levelcode::OneTimeCode).not_to receive(:issue) + + post '/ai/auth/verify', + params: { email: 'dev@example.com', code: '123456', + redirect_uri: 'levelcode-dev://levelcode.levelcode-ai/auth/callback', code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(json).to eq('redirect' => '/ai/account') + end + it 'rejects an invalid code' do allow(Levelcode::EmailCode).to receive(:verify).and_return(false) post '/ai/auth/verify', params: { email: 'nope@example.com', code: '000000' } @@ -435,6 +450,42 @@ def sign_in_browser(code) expect(response).to have_http_status(:unprocessable_content) end + # WHICH addresses are the editor's. The scheme is the editor build's identity; the host is the + # extension id and the path its auth route. One example per address, so that the list cannot + # grow or shrink without one of these changing. + { + 'levelcode://levelcode.levelcode-ai/auth/callback' => 'the shipped editor', + 'atom-plus-plus://levelcode.levelcode-ai/auth/callback' => 'a build from before the rename' + }.each_with_index do |(address, whose), i| + it "hands the code to #{whose} — #{address}" do + sign_in_browser("sess-takes-#{i}") + allow(Levelcode::OneTimeCode).to receive(:issue).with(user, 'chal').and_return('bound-code') + + post '/ai/authorize_editor', params: { redirect_uri: "#{address}?windowId=2", code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(json['redirect']).to eq("#{address}?windowId=2&code=bound-code") + end + end + + { + 'levelcode-dev://levelcode.levelcode-ai/auth/callback' => 'a scheme that is not on the list', + 'levelcode://evil.example/auth/callback' => 'the right scheme on another host', + 'levelcode://levelcode.levelcode-ai/elsewhere' => 'the right host on another path', + 'https://levelcode.levelcode-ai/auth/callback' => 'the right host and path over https', + 'levelcode:levelcode.levelcode-ai/auth/callback' => 'an address with no host at all' + }.each_with_index do |(address, what), i| + it "mints nothing for #{what} — #{address}" do + sign_in_browser("sess-refuses-#{i}") + expect(Levelcode::OneTimeCode).not_to receive(:issue) + + post '/ai/authorize_editor', params: { redirect_uri: address, code_challenge: 'chal' } + + expect(response).to have_http_status(:unprocessable_content) + expect(json.dig('error', 'code')).to eq('invalid_request') + end + end + it 'does not mint for an anonymous request' do post '/ai/authorize_editor', params: { redirect_uri: editor_uri, code_challenge: 'chal' }, as: :json expect(response).not_to have_http_status(:ok) From 3eaa0c165bb5c18ba42cf80a87ff4347e095ae63 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 3 Oct 2026 11:49:23 -0400 Subject: [PATCH 2/3] =?UTF-8?q?refactor(levelcode):=20one=20rule=20for=20t?= =?UTF-8?q?he=20editor's=20callback=20=E2=80=94=20Levelcode::EditorCallbac?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Levelcode::WebController and the API's AuthController each decided for themselves whether a redirect_uri was the editor's: two lists of schemes, the host and the path written out twice. Nothing kept them in step, and adding a scheme to one would have left a sign-in that works through /ai and not through /auth/login, or the reverse. The rule moves to Levelcode::EditorCallback — the schemes, the extension id as host, the auth route as path — and both controllers ask it. No behaviour changes: the request examples from the previous commit are untouched and pass as they were. What that buys shows in the mutations. Before, loosening the rule in one controller failed that controller's example. Now each of them — the pre-rename scheme dropped, another scheme added, the host or the path not compared — fails the example at BOTH gates, and either controller going back to its own check fails its own. The class gets a spec of its own for what a request cannot easily show: a URI or a string, the scheme's case, a host that merely starts with the extension id, an address with no host, and input that is not an address at all — false, never an exception. WebController's EDITOR_CALLBACK and EDITOR_SCHEMES are gone; nothing else read them. --- .../api/levelcode/v1/auth_controller.rb | 8 +-- app/controllers/levelcode/web_controller.rb | 23 ++------- app/services/levelcode/editor_callback.rb | 46 +++++++++++++++++ .../levelcode/editor_callback_spec.rb | 50 +++++++++++++++++++ 4 files changed, 102 insertions(+), 25 deletions(-) create mode 100644 app/services/levelcode/editor_callback.rb create mode 100644 spec/services/levelcode/editor_callback_spec.rb diff --git a/app/controllers/api/levelcode/v1/auth_controller.rb b/app/controllers/api/levelcode/v1/auth_controller.rb index dafd7ea7..47ee7703 100644 --- a/app/controllers/api/levelcode/v1/auth_controller.rb +++ b/app/controllers/api/levelcode/v1/auth_controller.rb @@ -237,12 +237,8 @@ def safe_redirect_uri(raw) return nil if raw.blank? uri = URI.parse(raw) - # Editor deep-link: host = extension id (levelcode.levelcode-ai), path pinned. Accept the - # current `levelcode` scheme and the legacy `atom-plus-plus` (pre-rename builds) during the - # transition; host + path stay pinned so this can't become an open redirect. - if %w[levelcode atom-plus-plus].include?(uri.scheme) && uri.host == "levelcode.levelcode-ai" && uri.path == "/auth/callback" - return uri - end + # The editor's deep link. Levelcode::EditorCallback has the rule, shared with the /ai flows. + return uri if ::Levelcode::EditorCallback.current.match?(uri) site_host = URI(ENV["SITE_ORIGIN"].presence || "https://levelcode.ai").host return uri if uri.scheme == "https" && uri.host == site_host diff --git a/app/controllers/levelcode/web_controller.rb b/app/controllers/levelcode/web_controller.rb index 866ebb90..73f12dbb 100644 --- a/app/controllers/levelcode/web_controller.rb +++ b/app/controllers/levelcode/web_controller.rb @@ -29,14 +29,6 @@ class WebController < ApplicationController before_action :authenticate_user!, only: %i[checkout billing authorize_editor] - # The frozen editor deep-link callback (SHARED CONTRACT). Host = the extension id - # . = levelcode.levelcode-ai; path = /auth/callback. - EDITOR_CALLBACK = "levelcode://levelcode.levelcode-ai/auth/callback" - # Accepted url schemes for the editor deep-link. `levelcode` is the current product urlProtocol; - # `atom-plus-plus` is the pre-rename scheme, still emitted by editor builds not yet rebuilt — - # accepted during the transition. The security-relevant host + path stay strictly pinned. - EDITOR_SCHEMES = %w[levelcode atom-plus-plus].freeze - # Per-IP OTP-send cap. The recipient is caller-chosen, so EmailCode's per-email # resend window alone can be fanned out across many addresses — bound by source IP. EMAIL_SEND_WINDOW = 1.hour.to_i @@ -272,7 +264,7 @@ def editor_callback_with_code(uri_str, code) # --- redirect_uri validation (no open redirect, never tokens in a URL) ---- # - # Accept ONLY the editor deep-link, pinned by scheme + host + path. We must + # Accept ONLY the editor deep-link (Levelcode::EditorCallback has the rule). We must # tolerate a query string (VS Code's asExternalUri appends ?windowId=N to route # the callback to the right editor window) and percent-encoding (the value can # arrive single- OR double-encoded through the login → OAuth hops), but NOTHING @@ -298,17 +290,10 @@ def decode_deep_link(raw) s end - # True when uri_str is the editor callback deep-link. Scheme + host + path are - # pinned to EDITOR_CALLBACK; any query (e.g. ?windowId=N) is allowed and gets - # preserved by editor_callback_with_code. + # True when uri_str is the editor callback deep-link. Any query (e.g. ?windowId=N) is allowed + # and gets preserved by editor_callback_with_code. def editor_deep_link?(uri_str) - return false if uri_str.blank? - - u = URI.parse(uri_str.to_s) - e = URI.parse(EDITOR_CALLBACK) - EDITOR_SCHEMES.include?(u.scheme) && u.host == e.host && u.path == e.path - rescue URI::InvalidURIError - false + uri_str.present? && Levelcode::EditorCallback.current.match?(uri_str) end # --- Session-stashed editor context (OAuth round-trip) -------------------- diff --git a/app/services/levelcode/editor_callback.rb b/app/services/levelcode/editor_callback.rb new file mode 100644 index 00000000..35a3e0dc --- /dev/null +++ b/app/services/levelcode/editor_callback.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Levelcode + # The one address a sign-in may hand its one-time code to: the editor's own deep link. + # + # It has three parts and only one of them varies. The host is the extension id + # (.) and the path is its auth route — both exact. The scheme is the editor + # BUILD's, its product urlProtocol, so it is a short list rather than a single value. + # + # Everything else is refused, because whatever passes gets a one-time code appended to it: a wider + # rule here is an open redirect that carries a credential. A query is allowed — the editor adds + # ?windowId=N so the callback reaches the window that asked — and is the caller's to preserve. + # + # Levelcode::WebController (the /ai flows) and the API's AuthController both ask this, so the two + # gates cannot disagree about what an editor is. + class EditorCallback + HOST = "levelcode.levelcode-ai" + PATH = "/auth/callback" + # `levelcode` is the shipped editor. `atom-plus-plus` is what a build from before the rename + # still sends. + SCHEMES = %w[levelcode atom-plus-plus].freeze + + class << self + # The rule for this process. + def current + @current ||= new + end + end + + attr_reader :schemes + + def initialize + @schemes = SCHEMES + freeze + end + + # Is `address` — a URI or a string — the editor's callback? Never raises: an address that does + # not parse is not the editor's. + def match?(address) + uri = URI.parse(address.to_s) + schemes.include?(uri.scheme) && uri.host == HOST && uri.path == PATH + rescue URI::InvalidURIError + false + end + end +end diff --git a/spec/services/levelcode/editor_callback_spec.rb b/spec/services/levelcode/editor_callback_spec.rb new file mode 100644 index 00000000..438a3c28 --- /dev/null +++ b/spec/services/levelcode/editor_callback_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Levelcode::EditorCallback do + subject(:callback) { described_class.new } + + let(:address) { "levelcode://levelcode.levelcode-ai/auth/callback" } + + describe "#match?" do + it "is true for the editor's deep link, with or without a query" do + expect(callback.match?(address)).to be(true) + expect(callback.match?("#{address}?windowId=3")).to be(true) + end + + it "takes a URI as readily as a string" do + expect(callback.match?(URI.parse("#{address}?windowId=3"))).to be(true) + end + + it "is true for each scheme on the list, and for no other" do + expect(callback.schemes).to eq(%w[levelcode atom-plus-plus]) + expect(callback.match?("atom-plus-plus://levelcode.levelcode-ai/auth/callback")).to be(true) + expect(callback.match?("levelcode-dev://levelcode.levelcode-ai/auth/callback")).to be(false) + expect(callback.match?("https://levelcode.levelcode-ai/auth/callback")).to be(false) + end + + it "reads the scheme as a URL does — its case is not part of it" do + expect(callback.match?("LevelCode://levelcode.levelcode-ai/auth/callback")).to be(true) + end + + it "compares the host and the path exactly" do + expect(callback.match?("levelcode://evil.example/auth/callback")).to be(false) + expect(callback.match?("levelcode://levelcode.levelcode-ai.evil.example/auth/callback")).to be(false) + expect(callback.match?("levelcode://levelcode.levelcode-ai/auth/callback/extra")).to be(false) + expect(callback.match?("levelcode://levelcode.levelcode-ai/auth")).to be(false) + expect(callback.match?("levelcode://levelcode.levelcode-ai")).to be(false) + end + + it "is false for an address with no host — the extension id sitting in the path proves nothing" do + expect(callback.match?("levelcode:levelcode.levelcode-ai/auth/callback")).to be(false) + expect(callback.match?("levelcode:/auth/callback")).to be(false) + end + + it "is false — never an exception — for what is not an address at all" do + expect(callback.match?("levelcode://not a url")).to be(false) + expect(callback.match?("")).to be(false) + expect(callback.match?(nil)).to be(false) + end + end +end From bd38f38f51210da2afa224aa042a0052d05d7fd0 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 3 Oct 2026 11:55:02 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(levelcode):=20a=20development=20editor?= =?UTF-8?q?=20can=20sign=20in=20=E2=80=94=20on=20servers=20told=20to=20tak?= =?UTF-8?q?e=20its=20scheme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An editor run from source shares the installed app's identity: same bundle id, same levelcode:// scheme. macOS therefore hands the sign-in callback to the app in /Applications, and the editor that asked never hears back. The editor is getting its own scheme for that, levelcode-dev (levelcodeai/levelcode), and a server has to be willing to hand a code to it. No server is, unless told: LEVELCODE_EXTRA_EDITOR_SCHEMES comma list of further schemes to accept Unset, the rule is exactly what it was: the shipped schemes and nothing else. That is production. A development server sets it beside LEVELCODE_HOSTS. What the setting may add is deliberately narrow: levelcode-, and nothing else. The one-time code is appended to whatever passes this rule and the browser is sent there, so a list that could be talked into `https` would post the code to a web host, and one that took `javascript` would run script on the account page. Saying what an entry must look like closes both without a list of schemes to forbid. The host and the path stay exact for every scheme. An entry that is not usable is dropped and named once in the log. Otherwise a typo is invisible: its only symptom is the sign-in it was meant to allow ending on the account page in the browser. Extra schemes are added to the shipped ones, never in place of them, so the setting cannot switch off the editor people have installed. Specs: - the rule itself: the comma list, what may and may not be added, entries reported as written, .from_env on a hash, .current built once. - through both gates, on a server told to take levelcode-dev: the code goes to the development editor with ?windowId kept, double-encoded as the login page passes it; the shipped editor still gets its own; another host on the development scheme gets nothing; https gets nothing even when the setting names it; no code_challenge still fails closed. - the default stays pinned by the examples from two commits ago, now run on a rule built with no setting whatever the machine has exported — a developer who has the variable in their shell would otherwise see three of them fail. Checked in a booted app, not only through the stand-in the request specs use: unset gives the shipped list, levelcode-dev adds itself, and https beside it is ignored and logged. Ten mutations of the rule each fail an example that names them. Suite: 1119 examples, 0 failures. --- app/services/levelcode/editor_callback.rb | 50 +++++++- spec/requests/api/levelcode/v1/auth_spec.rb | 17 +++ spec/requests/levelcode/web_spec.rb | 78 ++++++++++++ .../levelcode/editor_callback_spec.rb | 113 ++++++++++++++++++ spec/support/auth_helpers.rb | 6 + 5 files changed, 259 insertions(+), 5 deletions(-) diff --git a/app/services/levelcode/editor_callback.rb b/app/services/levelcode/editor_callback.rb index 35a3e0dc..43acbce1 100644 --- a/app/services/levelcode/editor_callback.rb +++ b/app/services/levelcode/editor_callback.rb @@ -13,24 +13,64 @@ module Levelcode # # Levelcode::WebController (the /ai flows) and the API's AuthController both ask this, so the two # gates cannot disagree about what an editor is. + # + # An editor run from source has its own scheme, `levelcode-dev`: with the shipped one, macOS hands + # the callback to the installed app instead of the editor that asked. No server takes it unless + # told to: + # + # LEVELCODE_EXTRA_EDITOR_SCHEMES comma list of further schemes to accept, e.g. `levelcode-dev` + # + # Unset — production — the list is the shipped schemes and nothing else. Set it on the server a + # development editor signs in to, beside LEVELCODE_HOSTS (Levelcode::Hosts). When a development + # editor's sign-in ends on the account page in the browser and the editor hears nothing, this is + # the setting that is missing. class EditorCallback HOST = "levelcode.levelcode-ai" PATH = "/auth/callback" # `levelcode` is the shipped editor. `atom-plus-plus` is what a build from before the rename # still sends. SCHEMES = %w[levelcode atom-plus-plus].freeze + # What the setting may add: a LevelCode build's scheme, `levelcode-`, and nothing else. + # The code is appended to whatever this lets through and the browser is sent there, so a list + # that could be talked into `https` would post the code to a web host, and one that took + # `javascript` would run script on the account page. Saying what an entry must look like closes + # both without a list of schemes to forbid. + EXTRA_SCHEME = /\Alevelcode-[a-z0-9]+(?:[.-][a-z0-9]+)*\z/ + ENV_KEY = "LEVELCODE_EXTRA_EDITOR_SCHEMES" class << self - # The rule for this process. + # The rule for this process, read from the environment once. def current - @current ||= new + @current ||= from_env.tap { |rule| warn_about(rule.ignored) } + end + + def from_env(env = ENV) + new(extra_schemes: env.fetch(ENV_KEY, "")) + end + + private + + # A typo in the setting is otherwise invisible: the entry is dropped, and the only symptom is + # the sign-in it was meant to allow going to the web account instead. + def warn_about(ignored) + return if ignored.empty? + + Rails.logger.warn("[levelcode] #{ENV_KEY}: ignoring #{ignored.map(&:inspect).join(', ')} — " \ + "an extra editor scheme looks like levelcode-dev") end end - attr_reader :schemes + # schemes: every scheme accepted — the shipped ones, then the extra ones that were usable. + # ignored: entries of the setting that were not, as written. + attr_reader :schemes, :ignored - def initialize - @schemes = SCHEMES + # extra_schemes: a comma list — entries are trimmed and case-folded, blanks and repeats dropped. + # A shipped scheme named again is neither added nor reported: it is taken already. + def initialize(extra_schemes: "") + entries = extra_schemes.to_s.split(",").map(&:strip).reject { |entry| entry.empty? || SCHEMES.include?(entry.downcase) } + usable, ignored = entries.partition { |entry| entry.downcase.match?(EXTRA_SCHEME) } + @schemes = (SCHEMES + usable.map(&:downcase)).uniq.freeze + @ignored = ignored.freeze freeze end diff --git a/spec/requests/api/levelcode/v1/auth_spec.rb b/spec/requests/api/levelcode/v1/auth_spec.rb index 0edf8903..45a97737 100644 --- a/spec/requests/api/levelcode/v1/auth_spec.rb +++ b/spec/requests/api/levelcode/v1/auth_spec.rb @@ -27,6 +27,10 @@ allow(Stripe::Customer).to receive(:retrieve).and_return(Stripe::Customer.construct_from(id: 'cus_test')) end + # A server that has been told nothing: the shipped editor schemes only, whatever the machine + # running the suite has exported (LEVELCODE_EXTRA_EDITOR_SCHEMES). Examples that opt in say so. + before { with_extra_editor_schemes('') } + let(:password) { 'password123' } let!(:user) { create(:user, email: 'editor@example.com', password: password) } let(:redirect_uri) { 'levelcode://levelcode.levelcode-ai/auth/callback' } @@ -111,6 +115,19 @@ def json end end + it '302s the code to a development editor once the server is told to take its scheme' do + with_extra_editor_schemes('levelcode-dev') + + post '/api/levelcode/v1/auth/login', + params: { email: user.email, password: password, code_challenge: 'chal', + redirect_uri: 'levelcode-dev://levelcode.levelcode-ai/auth/callback?windowId=2' } + + expect(response).to have_http_status(:found) + location = response.headers['Location'] + expect(location).to start_with('levelcode-dev://levelcode.levelcode-ai/auth/callback?windowId=2&code=') + expect(Rack::Utils.parse_query(URI.parse(location).query)['code']).to be_present + end + it 'refuses a non-https/non-editor redirect_uri (no open redirect)' do post '/api/levelcode/v1/auth/login', params: { email: user.email, password: password, redirect_uri: 'http://evil.example.com' } diff --git a/spec/requests/levelcode/web_spec.rb b/spec/requests/levelcode/web_spec.rb index a3be0c4f..dfe0eeee 100644 --- a/spec/requests/levelcode/web_spec.rb +++ b/spec/requests/levelcode/web_spec.rb @@ -20,6 +20,11 @@ host! 'www.example.com' end + # A server that has been told nothing: the shipped editor schemes only. Said outright, so the + # machine running the suite cannot say otherwise — a developer may well have + # LEVELCODE_EXTRA_EDITOR_SCHEMES exported for their own server. Examples that opt in say so. + before { with_extra_editor_schemes('') } + let(:editor_uri) { 'levelcode://levelcode.levelcode-ai/auth/callback' } def json = JSON.parse(response.body) @@ -239,6 +244,33 @@ def json = JSON.parse(response.body) expect(json).to eq('redirect' => '/ai/account') end + # …and on a server told to take it, the same request is an editor sign-in. + it 'hands a development editor its code once the server is told to take its scheme' do + with_extra_editor_schemes('levelcode-dev') + allow(Levelcode::EmailCode).to receive(:verify).and_return(true) + allow(Levelcode::OneTimeCode).to receive(:issue).and_return('one-time-code') + + # Double-encoded, as the editor's ?windowId tail arrives through the login page. + post '/ai/auth/verify', + params: { email: 'dev@example.com', code: '123456', + redirect_uri: 'levelcode-dev://levelcode.levelcode-ai/auth/callback%3FwindowId%3D1', code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(json).to eq('redirect' => 'levelcode-dev://levelcode.levelcode-ai/auth/callback?windowId=1&code=one-time-code') + end + + it 'still fails closed for a development editor with no code_challenge' do + with_extra_editor_schemes('levelcode-dev') + allow(Levelcode::EmailCode).to receive(:verify).and_return(true) + expect(Levelcode::OneTimeCode).not_to receive(:issue) + + post '/ai/auth/verify', + params: { email: 'dev@example.com', code: '123456', redirect_uri: 'levelcode-dev://levelcode.levelcode-ai/auth/callback' } + + expect(response).to have_http_status(:unprocessable_content) + expect(json.dig('error', 'code')).to eq('link_expired') + end + it 'rejects an invalid code' do allow(Levelcode::EmailCode).to receive(:verify).and_return(false) post '/ai/auth/verify', params: { email: 'nope@example.com', code: '000000' } @@ -486,6 +518,52 @@ def sign_in_browser(code) end end + context 'on a server told to take a development editor (LEVELCODE_EXTRA_EDITOR_SCHEMES)' do + before { with_extra_editor_schemes('levelcode-dev') } + + it 'hands the code to the development editor' do + sign_in_browser('sess-dev-a') + allow(Levelcode::OneTimeCode).to receive(:issue).with(user, 'chal').and_return('bound-code') + + post '/ai/authorize_editor', + params: { redirect_uri: 'levelcode-dev://levelcode.levelcode-ai/auth/callback?windowId=2', code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(json['redirect']).to eq('levelcode-dev://levelcode.levelcode-ai/auth/callback?windowId=2&code=bound-code') + end + + it 'goes on handing it to the shipped editor — the list grew, it was not replaced' do + sign_in_browser('sess-dev-b') + allow(Levelcode::OneTimeCode).to receive(:issue).with(user, 'chal').and_return('bound-code') + + post '/ai/authorize_editor', params: { redirect_uri: editor_uri, code_challenge: 'chal' } + + expect(response).to have_http_status(:ok) + expect(json['redirect']).to eq("#{editor_uri}?code=bound-code") + end + + it 'mints nothing for the development scheme on another host' do + sign_in_browser('sess-dev-c') + expect(Levelcode::OneTimeCode).not_to receive(:issue) + + post '/ai/authorize_editor', params: { redirect_uri: 'levelcode-dev://evil.example/auth/callback', code_challenge: 'chal' } + + expect(response).to have_http_status(:unprocessable_content) + end + end + + # A list that could be talked into a web scheme would post the code to a web host; the setting + # cannot add one, whatever it says. + it 'mints nothing for https even on a server whose setting names it' do + with_extra_editor_schemes('https, levelcode-dev') + sign_in_browser('sess-https') + expect(Levelcode::OneTimeCode).not_to receive(:issue) + + post '/ai/authorize_editor', params: { redirect_uri: 'https://levelcode.levelcode-ai/auth/callback', code_challenge: 'chal' } + + expect(response).to have_http_status(:unprocessable_content) + end + it 'does not mint for an anonymous request' do post '/ai/authorize_editor', params: { redirect_uri: editor_uri, code_challenge: 'chal' }, as: :json expect(response).not_to have_http_status(:ok) diff --git a/spec/services/levelcode/editor_callback_spec.rb b/spec/services/levelcode/editor_callback_spec.rb index 438a3c28..55fc4d8c 100644 --- a/spec/services/levelcode/editor_callback_spec.rb +++ b/spec/services/levelcode/editor_callback_spec.rb @@ -47,4 +47,117 @@ expect(callback.match?(nil)).to be(false) end end + + # A development editor has its own scheme, and no server takes it unless told to. + describe "extra schemes" do + let(:dev_address) { "levelcode-dev://levelcode.levelcode-ai/auth/callback" } + + it "are none by default — the shipped schemes are the whole list" do + expect(callback.schemes).to eq(%w[levelcode atom-plus-plus]) + expect(callback.ignored).to eq([]) + expect(callback.match?(dev_address)).to be(false) + end + + it "are taken from a comma list — trimmed, case-folded, blanks and repeats dropped" do + rule = described_class.new(extra_schemes: " levelcode-dev, LevelCode-Insiders ,,levelcode-dev,") + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus levelcode-dev levelcode-insiders]) + expect(rule.ignored).to eq([]) + end + + it "are accepted on top of the shipped schemes, never in place of them" do + rule = described_class.new(extra_schemes: "levelcode-dev") + expect(rule.match?("#{dev_address}?windowId=1")).to be(true) + expect(rule.match?(address)).to be(true) + expect(rule.match?("atom-plus-plus://levelcode.levelcode-ai/auth/callback")).to be(true) + end + + it "leave the host and the path as exact as they were" do + rule = described_class.new(extra_schemes: "levelcode-dev") + expect(rule.match?("levelcode-dev://evil.example/auth/callback")).to be(false) + expect(rule.match?("levelcode-dev://levelcode.levelcode-ai/elsewhere")).to be(false) + end + + # The code is appended to whatever passes and the browser is sent there. `https` would post it + # to a web host; `javascript` would run script on the account page. + it "must be a LevelCode build's scheme — nothing a browser resolves itself can be added" do + unusable = %w[https http javascript data file ws ftp vbscript intent about] + rule = described_class.new(extra_schemes: unusable.join(",")) + + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus]) + expect(rule.ignored).to eq(unusable) + unusable.each do |scheme| + expect(rule.match?("#{scheme}://levelcode.levelcode-ai/auth/callback")).to be(false), scheme + end + end + + it "must be spelled as one: levelcode-" do + misspelt = %w[levelcode_dev levelcode- -dev dev levelcode-dev:// levelcode--dev levelcode-dev- xlevelcode-dev levelcode-dév] + rule = described_class.new(extra_schemes: misspelt.join(",")) + + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus]) + expect(rule.ignored).to eq(misspelt) + end + + it "report an unusable entry as it was written, beside the ones that were taken" do + rule = described_class.new(extra_schemes: "levelcode-dev, HTTPS") + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus levelcode-dev]) + expect(rule.ignored).to eq(%w[HTTPS]) + end + + it "do not report a shipped scheme named again — it is taken already" do + rule = described_class.new(extra_schemes: "levelcode, Atom-Plus-Plus, levelcode-dev") + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus levelcode-dev]) + expect(rule.ignored).to eq([]) + end + + it "are none for an empty or missing list" do + expect(described_class.new(extra_schemes: "").schemes).to eq(%w[levelcode atom-plus-plus]) + expect(described_class.new(extra_schemes: nil).schemes).to eq(%w[levelcode atom-plus-plus]) + end + end + + # ENV is passed in rather than read, so "the list really comes from the environment" is an + # assertion about behaviour (as in Levelcode::Hosts). + describe ".from_env" do + it "reads the extra schemes from the environment it is given" do + rule = described_class.from_env("LEVELCODE_EXTRA_EDITOR_SCHEMES" => "levelcode-dev") + expect(rule.schemes).to eq(%w[levelcode atom-plus-plus levelcode-dev]) + end + + it "takes no extra scheme when the setting is absent — what production runs with" do + expect(described_class.from_env({}).schemes).to eq(%w[levelcode atom-plus-plus]) + end + end + + describe ".current" do + # Its own memo, so the process-wide rule is never disturbed. + let(:fresh) { Class.new(described_class) } + + it "is built from the environment, once" do + expect(fresh).to receive(:from_env).once.and_call_original + expect(fresh.current).to equal(fresh.current) + expect(fresh.current).to be_a(described_class) + end + + it "says so in the log, once, when the setting names something it will not take" do + allow(fresh).to receive(:from_env).and_return(described_class.new(extra_schemes: "levelcode-dev, https")) + expect(Rails.logger).to receive(:warn).once.with(/LEVELCODE_EXTRA_EDITOR_SCHEMES: ignoring "https"/) + + 2.times { fresh.current } + end + + it "says nothing when every entry was taken" do + allow(fresh).to receive(:from_env).and_return(described_class.new(extra_schemes: "levelcode-dev")) + expect(Rails.logger).not_to receive(:warn) + + fresh.current + end + end + + it "is immutable" do + rule = described_class.new(extra_schemes: "levelcode-dev, https") + expect(rule).to be_frozen + expect(rule.schemes).to be_frozen + expect(rule.ignored).to be_frozen + end end diff --git a/spec/support/auth_helpers.rb b/spec/support/auth_helpers.rb index 5497c299..5b9ea736 100644 --- a/spec/support/auth_helpers.rb +++ b/spec/support/auth_helpers.rb @@ -4,4 +4,10 @@ def auth_headers(user) token = Warden::JWTAuth::UserEncoder.new.call(user, :user, nil).first { 'Authorization' => "Bearer #{token}" } end + + # Run an example on a server told to take further editor schemes — what + # LEVELCODE_EXTRA_EDITOR_SCHEMES would set (Levelcode::EditorCallback). + def with_extra_editor_schemes(list) + allow(Levelcode::EditorCallback).to receive(:current).and_return(Levelcode::EditorCallback.new(extra_schemes: list)) + end end