Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions app/controllers/api/levelcode/v1/auth_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 4 additions & 19 deletions app/controllers/levelcode/web_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
# <publisher>.<name> = 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
Expand Down Expand Up @@ -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
Expand All @@ -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) --------------------
Expand Down
86 changes: 86 additions & 0 deletions app/services/levelcode/editor_callback.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 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
# (<publisher>.<name>) 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.
#
# 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-<variant>`, 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, read from the environment once.
def current
@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

# 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

# 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

# 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
49 changes: 49 additions & 0 deletions spec/requests/api/levelcode/v1/auth_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -79,6 +83,51 @@ 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 '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' }
Expand Down
129 changes: 129 additions & 0 deletions spec/requests/levelcode/web_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -224,6 +229,48 @@ 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

# …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' }
Expand Down Expand Up @@ -435,6 +482,88 @@ 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

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)
Expand Down
Loading
Loading