redmine_sign_in 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5cde64e138fd24f3788baa8ff515bcc3708eac43ca8afb8133e5519ed8ad26e2
4
+ data.tar.gz: ba073f1a97a90f37c154489ccf272346ac184f047cebaaeb6b89fef3aadf8607
5
+ SHA512:
6
+ metadata.gz: 79f1718a0d06cdb90f91b17348a660397e53808dda38849f076a6bc33a7c8951dbf435a71255a7f66c584bb10b0da51e1ed2cd0bcfe163c9b789118aa14af1a7
7
+ data.tar.gz: fe637b8b3b240723d7ba0cd011d1688feb293387172df9c69465fe6970deed45fb92f2a5b5f36a75a3645fa95dde1ac65028e02adf2944a52fbc3361d043436e
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Renuo AG
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # Redmine Sign-In for Rails
2
+
3
+ Add Redmine sign-in to your Rails app. Lets users sign up for and sign in to your service with their Redmine accounts via OAuth 2.0.
4
+
5
+ Requires Redmine 7+ and Rails 7.1+.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem 'redmine_sign_in'
13
+ ```
14
+
15
+ ## Configuration
16
+
17
+ ### 1. Register an OAuth application in Redmine
18
+
19
+ In Redmine, go to your account settings → **Applications** → **Register your application**. Set:
20
+
21
+ - **Name** — anything
22
+ - **Redirect URI** — `https://your-app.example.com/redmine_sign_in/callback`
23
+
24
+ For local development, register a separate application with `http://localhost:3000/redmine_sign_in/callback`.
25
+
26
+ Save and copy the resulting **client ID** and **client secret**.
27
+
28
+ ### 2. Configure the gem
29
+
30
+ Run `bin/rails credentials:edit` and add:
31
+
32
+ ```yaml
33
+ redmine_sign_in:
34
+ host: https://redmine.example.com
35
+ client_id: [Your client ID]
36
+ client_secret: [Your client secret]
37
+ ```
38
+
39
+ Or in an initializer:
40
+
41
+ ```ruby
42
+ # config/initializers/redmine_sign_in.rb
43
+ Rails.application.configure do
44
+ config.redmine_sign_in.host = ENV['REDMINE_SIGN_IN_HOST']
45
+ config.redmine_sign_in.client_id = ENV['REDMINE_SIGN_IN_CLIENT_ID']
46
+ config.redmine_sign_in.client_secret = ENV['REDMINE_SIGN_IN_CLIENT_SECRET']
47
+ end
48
+ ```
49
+
50
+ `host` is the base URL of your Redmine instance (no trailing slash). The gem derives the authorize, token, and userinfo URLs from it:
51
+
52
+ - `#{host}/oauth/authorize`
53
+ - `#{host}/oauth/token`
54
+ - `#{host}/users/current.json`
55
+
56
+ You can override any of them with `config.redmine_sign_in.authorize_url`, `config.redmine_sign_in.token_url`, or `config.redmine_sign_in.userinfo_url`.
57
+
58
+ The callback mount point can be changed via `config.redmine_sign_in.root` (default: `redmine_sign_in`).
59
+
60
+ ## Usage
61
+
62
+ The gem provides a `redmine_sign_in_button` helper:
63
+
64
+ ```erb
65
+ <%= redmine_sign_in_button 'Sign in with Redmine', proceed_to: create_login_url %>
66
+ ```
67
+
68
+ When using Hotwire/Turbo, disable Turbo on the button:
69
+
70
+ ```erb
71
+ <%= redmine_sign_in_button 'Sign in with Redmine',
72
+ proceed_to: create_login_url,
73
+ data: { turbo: 'false' } %>
74
+ ```
75
+
76
+ After authenticating, the gem redirects to `proceed_to` with either:
77
+
78
+ - `flash[:redmine_sign_in][:access_token]` — the Redmine OAuth access token, or
79
+ - `flash[:redmine_sign_in][:error]` — an [OAuth error code](https://tools.ietf.org/html/rfc6749#section-4.1.2.1).
80
+
81
+ A typical login controller:
82
+
83
+ ```ruby
84
+ class LoginsController < ApplicationController
85
+ def new
86
+ end
87
+
88
+ def create
89
+ if user = authenticate_with_redmine
90
+ cookies.signed[:user_id] = user.id
91
+ redirect_to user
92
+ else
93
+ redirect_to new_session_url, alert: 'authentication_failed'
94
+ end
95
+ end
96
+
97
+ private
98
+ def authenticate_with_redmine
99
+ if access_token = flash[:redmine_sign_in][:access_token]
100
+ identity = RedmineSignIn::Identity.new(access_token)
101
+ User.find_by(redmine_id: identity.user_id)
102
+ elsif error = flash[:redmine_sign_in][:error]
103
+ logger.error "Redmine authentication error: #{error}"
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ ```
109
+
110
+ The `proceed_to` URL must be on the same origin as your app — this is enforced to prevent [open redirects](https://owasp.org/www-community/attacks/Unvalidated_Redirects_and_Forwards_Cheat_Sheet).
111
+
112
+ ### `RedmineSignIn::Identity`
113
+
114
+ Wraps an access token and lazily fetches `/users/current.json` on first attribute read. Use `user_id` (the stable numeric Redmine user ID) to match against your own user records.
115
+
116
+ - `user_id` — Redmine's numeric user id. Stable; prefer over email.
117
+ - `login` — Redmine login handle.
118
+ - `email_address` — the user's `mail` field.
119
+ - `name` — full name (`firstname lastname`).
120
+ - `given_name` — `firstname`.
121
+ - `family_name` — `lastname`.
122
+
123
+ Raises `RedmineSignIn::Identity::FetchError` if Redmine returns a non-success response or invalid JSON.
124
+
125
+ ## Development
126
+
127
+ ```sh
128
+ bin/setup # bundle install
129
+ bin/test # run the test suite
130
+ bin/lint # run rubocop
131
+ bin/dummy # boot the test/dummy app at http://localhost:3000
132
+ ```
133
+
134
+ The dummy app under `test/dummy` includes a welcome page (`/`) that renders
135
+ the `redmine_sign_in_button` helper, plus a `/login` endpoint that prints the
136
+ identity returned after the round-trip. Configure a real Redmine via env vars
137
+ before booting it:
138
+
139
+ ```sh
140
+ REDMINE_SIGN_IN_HOST=https://redmine.example.com \
141
+ REDMINE_SIGN_IN_CLIENT_ID=... \
142
+ REDMINE_SIGN_IN_CLIENT_SECRET=... \
143
+ bin/dummy
144
+ ```
145
+
146
+ ## Credits
147
+
148
+ This gem is heavily inspired by
149
+ [basecamp/google_sign_in](https://github.com/basecamp/google_sign_in). The
150
+ engine layout, controller flow, flash-based handoff, and `RedirectProtector`
151
+ all follow that gem's design. The main departure is that Redmine's OAuth2
152
+ provider is not OIDC, so the flash carries an access token (not an ID token)
153
+ and `RedmineSignIn::Identity` fetches `/users/current.json` instead of
154
+ verifying a JWT locally.
155
+
156
+ ## License
157
+
158
+ MIT.
@@ -0,0 +1,19 @@
1
+ require "securerandom"
2
+
3
+ class RedmineSignIn::AuthorizationsController < RedmineSignIn::BaseController
4
+ skip_forgery_protection only: :create
5
+
6
+ def create
7
+ redirect_to login_url(state: state),
8
+ allow_other_host: true, flash: { proceed_to: params.require(:proceed_to), state: state }
9
+ end
10
+
11
+ private
12
+ def login_url(**params)
13
+ client.auth_code.authorize_url(**params)
14
+ end
15
+
16
+ def state
17
+ @state ||= SecureRandom.base64(24)
18
+ end
19
+ end
@@ -0,0 +1,8 @@
1
+ class RedmineSignIn::BaseController < ActionController::Base
2
+ protect_from_forgery with: :exception
3
+
4
+ private
5
+ def client
6
+ @client ||= RedmineSignIn.oauth2_client(redirect_uri: callback_url)
7
+ end
8
+ end
@@ -0,0 +1,43 @@
1
+ require "redmine_sign_in/redirect_protector"
2
+
3
+ class RedmineSignIn::CallbacksController < RedmineSignIn::BaseController
4
+ def show
5
+ redirect_to proceed_to_url, flash: { redmine_sign_in: redmine_sign_in_response }
6
+ clear_redeemed_flash_keys if valid_request?
7
+ rescue RedmineSignIn::RedirectProtector::Violation => error
8
+ logger.error error.message
9
+ head :bad_request
10
+ end
11
+
12
+ private
13
+ def proceed_to_url
14
+ flash[:proceed_to].tap { |url| RedmineSignIn::RedirectProtector.ensure_same_origin(url, request.url) }
15
+ end
16
+
17
+ def redmine_sign_in_response
18
+ if valid_request? && params[:code].present?
19
+ { access_token: access_token }
20
+ else
21
+ { error: error_message_for(params[:error]) }
22
+ end
23
+ rescue OAuth2::Error => error
24
+ { error: error_message_for(error.code) }
25
+ end
26
+
27
+ def valid_request?
28
+ flash[:state].present? && params[:state] == flash[:state]
29
+ end
30
+
31
+ def access_token
32
+ client.auth_code.get_token(params[:code]).token
33
+ end
34
+
35
+ def error_message_for(error_code)
36
+ error_code.presence_in(RedmineSignIn::OAUTH2_ERRORS) || "invalid_request"
37
+ end
38
+
39
+ def clear_redeemed_flash_keys
40
+ flash.delete(:proceed_to)
41
+ flash.delete(:state)
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ module RedmineSignIn::ButtonHelper
2
+ def redmine_sign_in_button(text = nil, proceed_to:, **options, &block)
3
+ form_with url: redmine_sign_in.authorization_path, local: true do
4
+ hidden_field_tag(:proceed_to, proceed_to, id: nil) + button_tag(text, name: nil, **options, &block)
5
+ end
6
+ end
7
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ RedmineSignIn::Engine.routes.draw do
2
+ resource :authorization, only: :create
3
+ resource :callback, only: :show
4
+ end
@@ -0,0 +1,47 @@
1
+ require "rails/engine"
2
+ require "redmine_sign_in" unless defined?(RedmineSignIn)
3
+
4
+ module RedmineSignIn
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace RedmineSignIn
7
+
8
+ config.redmine_sign_in = ActiveSupport::OrderedOptions.new
9
+
10
+ initializer "redmine_sign_in.config" do |app|
11
+ config.after_initialize do
12
+ credentials = app.credentials.redmine_sign_in || {}
13
+
14
+ RedmineSignIn.client_id = config.redmine_sign_in.client_id || credentials[:client_id]
15
+ RedmineSignIn.client_secret = config.redmine_sign_in.client_secret || credentials[:client_secret]
16
+ RedmineSignIn.host = config.redmine_sign_in.host || credentials[:host]
17
+
18
+ if RedmineSignIn.host.present?
19
+ host = RedmineSignIn.host.chomp("/")
20
+ RedmineSignIn.authorize_url = config.redmine_sign_in.authorize_url || "#{host}/oauth/authorize"
21
+ RedmineSignIn.token_url = config.redmine_sign_in.token_url || "#{host}/oauth/token"
22
+ RedmineSignIn.userinfo_url = config.redmine_sign_in.userinfo_url || "#{host}/users/current.json"
23
+ else
24
+ RedmineSignIn.authorize_url = config.redmine_sign_in.authorize_url
25
+ RedmineSignIn.token_url = config.redmine_sign_in.token_url
26
+ RedmineSignIn.userinfo_url = config.redmine_sign_in.userinfo_url
27
+ end
28
+
29
+ RedmineSignIn.oauth2_client_options = config.redmine_sign_in.oauth2_client_options
30
+ end
31
+ end
32
+
33
+ config.to_prepare do
34
+ ActionController::Base.helper RedmineSignIn::Engine.helpers
35
+ end
36
+
37
+ initializer "redmine_sign_in.mount" do |app|
38
+ app.routes.prepend do
39
+ mount RedmineSignIn::Engine, at: app.config.redmine_sign_in.root || "redmine_sign_in"
40
+ end
41
+ end
42
+
43
+ initializer "redmine_sign_in.parameter_filters" do |app|
44
+ app.config.filter_parameters << :code
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,71 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module RedmineSignIn
6
+ class Identity
7
+ class FetchError < StandardError; end
8
+
9
+ def initialize(access_token, userinfo_url: RedmineSignIn.userinfo_url)
10
+ @access_token = access_token
11
+ @userinfo_url = userinfo_url
12
+ end
13
+
14
+ def user_id
15
+ payload["id"]
16
+ end
17
+
18
+ def login
19
+ payload["login"]
20
+ end
21
+
22
+ # Nil when the user has "Hide my email address" enabled in their Redmine
23
+ # account preferences and the OAuth token isn't admin — Redmine omits the
24
+ # `mail` field from /users/current.json in that case. Fall back to #login
25
+ # if your Redmine uses email-as-login.
26
+ def email_address
27
+ payload["mail"]
28
+ end
29
+
30
+ def name
31
+ [ payload["firstname"], payload["lastname"] ].compact.join(" ")
32
+ end
33
+
34
+ def given_name
35
+ payload["firstname"]
36
+ end
37
+
38
+ def family_name
39
+ payload["lastname"]
40
+ end
41
+
42
+ private
43
+ def payload
44
+ @payload ||= fetch_payload
45
+ end
46
+
47
+ def fetch_payload
48
+ if @userinfo_url.blank?
49
+ raise ArgumentError, "RedmineSignIn.userinfo_url (or RedmineSignIn.host) must be set to fetch identity"
50
+ end
51
+
52
+ uri = URI(@userinfo_url)
53
+ request = Net::HTTP::Get.new(uri)
54
+ request["Authorization"] = "Bearer #{@access_token}"
55
+ request["Accept"] = "application/json"
56
+
57
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
58
+ http.request(request)
59
+ end
60
+
61
+ unless response.is_a?(Net::HTTPSuccess)
62
+ raise FetchError, "Failed to fetch Redmine user from #{@userinfo_url}: #{response.code} #{response.message}"
63
+ end
64
+
65
+ body = JSON.parse(response.body)
66
+ body["user"] || body
67
+ rescue JSON::ParserError => error
68
+ raise FetchError, "Invalid JSON response from #{@userinfo_url}: #{error.message}"
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,35 @@
1
+ require "uri"
2
+
3
+ module RedmineSignIn
4
+ module RedirectProtector
5
+ extend self
6
+
7
+ class Violation < StandardError; end
8
+
9
+ QUALIFIED_URL_PATTERN = /\A#{URI::DEFAULT_PARSER.make_regexp}\z/
10
+
11
+ def ensure_same_origin(target, source)
12
+ unless uri_same_origin?(target, source) || absolute_path?(target)
13
+ raise Violation, "Redirect target #{target.inspect} does not have same origin as request #{source.inspect}"
14
+ end
15
+ end
16
+
17
+ private
18
+ def uri_same_origin?(target, source)
19
+ target =~ QUALIFIED_URL_PATTERN && origin_of(target) == origin_of(source)
20
+ rescue ArgumentError, URI::Error
21
+ false
22
+ end
23
+
24
+ def absolute_path?(target)
25
+ target =~ URI::DEFAULT_PARSER.regexp[:ABS_PATH] && URI(target).host.nil? && !target.start_with?("//")
26
+ rescue ArgumentError, URI::Error
27
+ false
28
+ end
29
+
30
+ def origin_of(url)
31
+ uri = URI(url)
32
+ "#{uri.scheme}://#{uri.host}:#{uri.port}"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,51 @@
1
+ require "active_support"
2
+ require "active_support/rails"
3
+ require "oauth2"
4
+
5
+ # Inspired by and structured after basecamp/google_sign_in
6
+ # (https://github.com/basecamp/google_sign_in).
7
+ module RedmineSignIn
8
+ mattr_accessor :client_id
9
+ mattr_accessor :client_secret
10
+ mattr_accessor :host
11
+ mattr_accessor :authorize_url
12
+ mattr_accessor :token_url
13
+ mattr_accessor :userinfo_url
14
+ mattr_accessor :oauth2_client_options, default: nil
15
+
16
+ # https://tools.ietf.org/html/rfc6749#section-4.1.2.1
17
+ authorization_request_errors = %w[
18
+ invalid_request
19
+ unauthorized_client
20
+ access_denied
21
+ unsupported_response_type
22
+ invalid_scope
23
+ server_error
24
+ temporarily_unavailable
25
+ ]
26
+
27
+ # https://tools.ietf.org/html/rfc6749#section-5.2
28
+ access_token_request_errors = %w[
29
+ invalid_request
30
+ invalid_client
31
+ invalid_grant
32
+ unauthorized_client
33
+ unsupported_grant_type
34
+ invalid_scope
35
+ ]
36
+
37
+ OAUTH2_ERRORS = authorization_request_errors | access_token_request_errors
38
+
39
+ def self.oauth2_client(redirect_uri:)
40
+ OAuth2::Client.new \
41
+ RedmineSignIn.client_id,
42
+ RedmineSignIn.client_secret,
43
+ authorize_url: RedmineSignIn.authorize_url,
44
+ token_url: RedmineSignIn.token_url,
45
+ redirect_uri: redirect_uri,
46
+ **RedmineSignIn.oauth2_client_options.to_h
47
+ end
48
+ end
49
+
50
+ require "redmine_sign_in/identity"
51
+ require "redmine_sign_in/engine" if defined?(Rails) && !defined?(RedmineSignIn::Engine)
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: redmine_sign_in
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon Isler
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 7.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 7.1.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: oauth2
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 2.0.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 2.0.0
41
+ description:
42
+ email:
43
+ - simon.isler@renuo.ch
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - MIT-LICENSE
49
+ - README.md
50
+ - app/controllers/redmine_sign_in/authorizations_controller.rb
51
+ - app/controllers/redmine_sign_in/base_controller.rb
52
+ - app/controllers/redmine_sign_in/callbacks_controller.rb
53
+ - app/helpers/redmine_sign_in/button_helper.rb
54
+ - config/routes.rb
55
+ - lib/redmine_sign_in.rb
56
+ - lib/redmine_sign_in/engine.rb
57
+ - lib/redmine_sign_in/identity.rb
58
+ - lib/redmine_sign_in/redirect_protector.rb
59
+ homepage: https://github.com/renuo/redmine_sign_in
60
+ licenses:
61
+ - MIT
62
+ metadata: {}
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 3.1.0
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.5.22
79
+ signing_key:
80
+ specification_version: 4
81
+ summary: Sign in with Redmine for Rails applications
82
+ test_files: []