omniauth-wecom 1.0.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: 20123a046b4db04e1a3afcf76aa4bf7593114df4bc7c67e06dfef9506418658d
4
+ data.tar.gz: ccba1c2ed792ec30ec7349d5aa9523b8c9795ca6a552b89d3def12b4ad1bbfa9
5
+ SHA512:
6
+ metadata.gz: dbaade7da462cbba6f25234be688fde4712b469af3867819a932040529f39d0b5324a56d9e8b5e56178e48f256714e9da50ddccce4d8563b14b278811834dfb1
7
+ data.tar.gz: a6790a76cc51c440bdc1c606582530cdf58966ef5f3730de1b3e2d1e6f5d7c09c83dfb615f92838c70c198e6464f20f5fc69684cbbc99b5b9acaf310b3e5ce0c
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## 1.0.0 (2026-09-08)
6
+
7
+ - Extract the WeCom CorpApp OmniAuth strategy from the JiHu GitLab monolith into a standalone gem.
8
+ - Add standalone tests, linting, gem packaging, and contributor documentation.
9
+
10
+ The extraction is tracked in [work item 5299](https://jihulab.com/gitlab-cn/gitlab/-/work_items/5299) and is based on [MR 3776](https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/3776).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 JiHu (Shanghai) Information Technology Co., Ltd.
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,77 @@
1
+ # omniauth-wecom
2
+
3
+ `omniauth-wecom` is an [OmniAuth](https://github.com/omniauth/omniauth) strategy for signing in through a WeCom (WeChat Work) custom, corp-internal application. It supports the desktop CorpApp QR-code flow. Third-party service-provider applications and the WeCom in-app WebView flow are outside its scope.
4
+
5
+ ## Installation
6
+
7
+ Add the gem to your `Gemfile`:
8
+
9
+ ```ruby
10
+ gem 'omniauth-wecom', '~> 1.0.0'
11
+ ```
12
+
13
+ Then run `bundle install`.
14
+
15
+ The gem supports Ruby 3.2 or later and OmniAuth 2.
16
+
17
+ ## Usage
18
+
19
+ ```ruby
20
+ use OmniAuth::Builder do
21
+ provider :wecom,
22
+ ENV.fetch('WECOM_CORP_ID'),
23
+ ENV.fetch('WECOM_CORP_SECRET'),
24
+ agent_id: ENV.fetch('WECOM_AGENT_ID')
25
+ end
26
+ ```
27
+
28
+ | Option | Required | Description |
29
+ | --- | --- | --- |
30
+ | `client_id` (first argument) | Yes | WeCom CorpID |
31
+ | `client_secret` (second argument) | Yes | Secret of the custom application |
32
+ | `agent_id` | Yes | AgentID of the custom application |
33
+ | `fetch_user_detail` | No | Fetch the member's name and email from the address book; defaults to `false` and requires the relevant permission |
34
+ | `authorize_url` | No | Override the QR-code authorization endpoint for development or a private host |
35
+ | `client_options[:site]` | No | Override the API host for development or a private host |
36
+
37
+ The host application must follow OmniAuth 2's integration requirements: start authentication with `POST`, preserve the session from the request phase through the callback, and keep request validation and CSRF protection enabled. The strategy stores an OmniAuth `state` value in the session and relies on `omniauth-oauth2` to reject a missing or mismatched value at callback time.
38
+
39
+ Host overrides are available for private endpoints and local stubs. Their presence does not imply official support for any particular private WeCom deployment.
40
+
41
+ ## Authentication flow
42
+
43
+ The access token belongs to the application, not the member. The strategy:
44
+
45
+ 1. Redirects the browser to WeCom's `CorpApp` QR-code endpoint.
46
+ 2. Exchanges the CorpID and application secret for a corp-level access token.
47
+ 3. Uses that token and the callback code to resolve the member's UserId.
48
+ 4. Optionally reads the member's name and email when `fetch_user_detail` is enabled.
49
+
50
+ Only corp members can sign in. Responses without a `userid`, including external contacts, are rejected. If WeCom rejects an access token, the strategy invalidates it and retries the identity lookup once. When the optional address-book lookup returns a WeCom API error, such as missing permission or a rejected token, the strategy falls back to the UserId instead of failing sign-in. Other network or HTTP failures are not covered by this fallback.
51
+
52
+ ## Identity and auth hash
53
+
54
+ The auth hash uses `"<CorpID>:<UserId>"` as `uid`, because a UserId is unique only within one corp. `info` always includes the UserId as `nickname`; it uses the UserId as `name` unless member details are available.
55
+
56
+ The corp-level token can grant broad application access, and the member response can contain a long-lived ticket. To keep both out of sessions and logs:
57
+
58
+ - `credentials` is empty.
59
+ - `extra` contains only `corp_id`.
60
+
61
+ ## Extension hooks and token storage
62
+
63
+ The strategy exposes five protected hooks for host applications:
64
+
65
+ - `parse_json(raw)` parses WeCom response bodies.
66
+ - `request_user_info` performs the member identity request.
67
+ - `request_user_detail` performs the optional address-book request.
68
+ - `corp_access_token` obtains the application token and returns `[token, expires_in]`.
69
+ - `invalidate_access_token` invalidates token storage after WeCom rejects a token.
70
+
71
+ The standalone defaults fetch a token directly from WeCom and do not provide shared token caching. GitLab-specific HTTP adapters, hardened JSON parsing, and `Gitlab::Wecom::AccessToken` caching remain in the GitLab monolith and are not part of this gem.
72
+
73
+ ## Development
74
+
75
+ See [CONTRIBUTING.md](CONTRIBUTING.md). This project is licensed under the MIT License; see [LICENSE](LICENSE).
76
+
77
+ The extraction was tracked in [work item 5299](https://jihulab.com/gitlab-cn/gitlab/-/work_items/5299), following the implementation merged in [MR 3776](https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/3776).
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+ require 'omniauth-oauth2'
6
+
7
+ module OmniAuth
8
+ module Strategies
9
+ # Sign-in with a WeCom (WeChat Work) custom, corp-internal application.
10
+ #
11
+ # This is not plain OAuth 2.0. The access token is issued to the *app*, not
12
+ # to the user: it comes from corpid plus corpsecret and has nothing to do
13
+ # with the authorization code. The code is presented at the last step,
14
+ # alongside that token, purely to resolve who signed in. That is why
15
+ # `build_access_token` is replaced wholesale.
16
+ #
17
+ # Only corp members can sign in. External contacts and WeChat users outside
18
+ # the corp come back without a userid and are rejected.
19
+ class Wecom < OmniAuth::Strategies::OAuth2
20
+ # Scan-the-QR-code entry point, for a normal desktop browser. Authorizing
21
+ # inside the WeCom client's own WebView uses a different endpoint and is
22
+ # not supported yet.
23
+ AUTHORIZE_URL = 'https://login.work.weixin.qq.com/wwlogin/sso/login'
24
+
25
+ API_SITE = 'https://qyapi.weixin.qq.com'
26
+ TOKEN_PATH = '/cgi-bin/gettoken'
27
+ USER_INFO_PATH = '/cgi-bin/auth/getuserinfo'
28
+ USER_DETAIL_PATH = '/cgi-bin/user/get'
29
+
30
+ # How extern_uid is encoded: CorpID and UserId joined by a colon.
31
+ #
32
+ # A UserId is only unique inside one corp, so it means nothing on its own.
33
+ # Carrying the CorpID means that after the instance switches corps, old
34
+ # bindings stop matching instead of quietly resolving to whoever happens
35
+ # to share that UserId in the new corp.
36
+ #
37
+ # WeCom UserIds allow letters, digits and `-_@.`, so a colon separates
38
+ # them unambiguously.
39
+ UID_SEPARATOR = ':'
40
+
41
+ # Raised when the provider is wired up without the settings CorpApp
42
+ # sign-in needs. Surfaced at request time rather than at boot so that one
43
+ # bad provider entry cannot stop the whole application from starting.
44
+ ConfigurationError = Class.new(StandardError)
45
+
46
+ # Raised when WeCom refuses the corp token, so it can be refreshed once.
47
+ TokenRejected = Class.new(StandardError)
48
+
49
+ option :name, 'wecom'
50
+
51
+ # AgentID of the custom app. Required: CorpApp scan-code sign-in is
52
+ # scoped to a single app and WeCom rejects the request without it.
53
+ option :agent_id, nil
54
+
55
+ # Reading a member's name and email needs the address book permission,
56
+ # which many customers will not grant, so it is off by default. Without
57
+ # it `info` carries only the userid and GitLab generates a placeholder
58
+ # email.
59
+ option :fetch_user_detail, false
60
+
61
+ # Overridable so the strategy can point at a WeCom private deployment,
62
+ # or at a local stub while developing.
63
+ option :authorize_url, AUTHORIZE_URL
64
+
65
+ # A rejected or expired token comes back as one of these; the caller
66
+ # refreshes once rather than failing the sign-in.
67
+ TOKEN_REJECTED_CODES = [40014, 41001, 42001].freeze
68
+
69
+ option :client_options, {
70
+ site: API_SITE,
71
+ connection_opts: { request: { timeout: 20, open_timeout: 10 } }
72
+ }
73
+
74
+ uid { "#{corp_id}#{UID_SEPARATOR}#{raw_user_info['userid']}" }
75
+
76
+ info do
77
+ {
78
+ name: user_detail['name'] || raw_user_info['userid'],
79
+ nickname: raw_user_info['userid'],
80
+ email: user_detail['email']
81
+ }
82
+ end
83
+
84
+ # `credentials` and `extra` are overridden as methods rather than as DSL
85
+ # blocks: OmniAuth merges the whole ancestor stack, so an empty block here
86
+ # would still let the OAuth2 base contribute its own keys.
87
+
88
+ # The token is corp-level: it can send messages and read the address book
89
+ # for the whole company. Sign-in needs none of it, so keep it out of the
90
+ # auth hash, which is stored in the session and shows up in logs.
91
+ def credentials
92
+ {}
93
+ end
94
+
95
+ # Deliberately not the raw payload: it also returns a member ticket, a
96
+ # credential that stays valid for weeks. Only the corp id is needed here.
97
+ def extra
98
+ { corp_id: corp_id }
99
+ end
100
+
101
+ def request_phase
102
+ if options.agent_id.nil? || options.agent_id.to_s.empty?
103
+ raise ConfigurationError, 'WeCom provider requires `agent_id` to be configured'
104
+ end
105
+
106
+ redirect authorize_url
107
+ end
108
+
109
+ # WeCom matches the trusted domain against the URL without its query
110
+ # string, so the callback must not carry one.
111
+ def callback_url
112
+ full_host + callback_path
113
+ end
114
+
115
+ def authorize_url
116
+ # Take the state from authorize_params: it also writes the value into
117
+ # the session, which is what callback_phase compares against. Rolling
118
+ # our own state here would silently disable the CSRF check.
119
+ state = authorize_params[:state]
120
+
121
+ query = {
122
+ login_type: 'CorpApp',
123
+ appid: corp_id,
124
+ agentid: options.agent_id,
125
+ redirect_uri: callback_url,
126
+ state: state
127
+ }.reject { |_, value| value.nil? || value.to_s.empty? }
128
+
129
+ "#{options.authorize_url}?#{URI.encode_www_form(query)}"
130
+ end
131
+
132
+ protected
133
+
134
+ def build_access_token
135
+ token, expires_in = corp_access_token
136
+
137
+ ::OAuth2::AccessToken.new(
138
+ client, token,
139
+ expires_in: expires_in,
140
+ # WeCom reads the token from the query string; it has no Bearer support
141
+ mode: :query,
142
+ param_name: 'access_token'
143
+ )
144
+ end
145
+
146
+ # Fetches the corp-level token straight from WeCom.
147
+ #
148
+ # Overridden inside GitLab to go through a shared cache: WeCom hands out
149
+ # one token per app, and asking for a new one can invalidate the token
150
+ # another part of the app is still using.
151
+ #
152
+ # @return [Array(String, Integer)] the token and its lifetime in seconds
153
+ def corp_access_token
154
+ body = parse_response(
155
+ client.request(:get, TOKEN_PATH, params: { corpid: corp_id, corpsecret: client.secret })
156
+ )
157
+
158
+ [body['access_token'], body['expires_in']]
159
+ end
160
+
161
+ # Overridden inside GitLab so the call goes through the instance's
162
+ # controlled HTTP client instead of the gem's own connection.
163
+ def request_user_info
164
+ access_token.get(USER_INFO_PATH, params: { code: request.params['code'] })
165
+ end
166
+
167
+ # Same reason as `request_user_info`: kept as a hook so GitLab can route
168
+ # it through the controlled HTTP client too.
169
+ def request_user_detail
170
+ access_token.get(USER_DETAIL_PATH, params: { userid: raw_user_info['userid'] })
171
+ end
172
+
173
+ # Overridden inside GitLab to evict the shared token cache.
174
+ def invalidate_access_token; end
175
+
176
+ # Overridden inside GitLab to use a hardened parser.
177
+ def parse_json(raw)
178
+ ::JSON.parse(raw)
179
+ end
180
+
181
+ def corp_id
182
+ client.id
183
+ end
184
+
185
+ private
186
+
187
+ def raw_user_info
188
+ @raw_user_info ||= fetch_user_info
189
+ end
190
+
191
+ def fetch_user_info
192
+ member_info
193
+ rescue TokenRejected
194
+ # The cached token was retired early. Drop it, get a new one, try once.
195
+ invalidate_access_token
196
+ self.access_token = build_access_token
197
+
198
+ begin
199
+ member_info
200
+ rescue TokenRejected => e
201
+ raise CallbackError.new(:invalid_credentials, e.message)
202
+ end
203
+ end
204
+
205
+ def member_info
206
+ info = parse_response(request_user_info)
207
+
208
+ if info['userid'].nil? || info['userid'].to_s.empty?
209
+ raise CallbackError.new(:invalid_credentials,
210
+ 'WeCom did not return a userid: the account is not a member of this corp')
211
+ end
212
+
213
+ info
214
+ end
215
+
216
+ def user_detail
217
+ return {} unless options.fetch_user_detail
218
+
219
+ @user_detail ||= begin
220
+ parse_response(request_user_detail)
221
+ rescue CallbackError, TokenRejected
222
+ # Details are optional. Whether the address book permission is missing
223
+ # or the token was retired between the two calls, fall back to the
224
+ # userid alone rather than failing the sign-in.
225
+ {}
226
+ end
227
+ end
228
+
229
+ # WeCom answers 200 even for failures and puts the real status in
230
+ # `errcode`, so the body is what decides success.
231
+ def parse_response(response)
232
+ body = parse_json(response.body)
233
+ errcode = body['errcode']
234
+
235
+ return body if errcode.nil? || errcode.to_i == 0
236
+
237
+ if TOKEN_REJECTED_CODES.include?(errcode.to_i)
238
+ raise TokenRejected, "WeCom rejected the access token: #{errcode} #{body['errmsg']}"
239
+ end
240
+
241
+ raise CallbackError.new(:invalid_credentials,
242
+ "WeCom API error #{errcode}: #{body['errmsg']}")
243
+ rescue ::JSON::ParserError
244
+ raise CallbackError.new(:invalid_credentials, 'WeCom returned a malformed response')
245
+ end
246
+ end
247
+ end
248
+ end
249
+
250
+ OmniAuth.config.add_camelization 'wecom', 'Wecom'
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module Wecom
5
+ VERSION = '1.0.0'
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'omniauth/wecom/version'
4
+ require 'omniauth/strategies/wecom'
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'omniauth/wecom'
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omniauth-wecom
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - JiHu GitLab
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: omniauth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: omniauth-oauth2
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.8'
40
+ description: OmniAuth strategy for signing in with a WeCom custom (internal) application.
41
+ executables: []
42
+ extensions: []
43
+ extra_rdoc_files: []
44
+ files:
45
+ - CHANGELOG.md
46
+ - LICENSE
47
+ - README.md
48
+ - lib/omniauth-wecom.rb
49
+ - lib/omniauth/strategies/wecom.rb
50
+ - lib/omniauth/wecom.rb
51
+ - lib/omniauth/wecom/version.rb
52
+ homepage: https://gitlab.com/gitlab-jh/jh-team/omniauth-wecom
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ source_code_uri: https://gitlab.com/gitlab-jh/jh-team/omniauth-wecom
57
+ bug_tracker_uri: https://gitlab.com/gitlab-jh/jh-team/omniauth-wecom/-/issues
58
+ changelog_uri: https://gitlab.com/gitlab-jh/jh-team/omniauth-wecom/-/blob/main/CHANGELOG.md
59
+ allowed_push_host: https://rubygems.org
60
+ rubygems_mfa_required: 'true'
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.2'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 4.0.13
76
+ specification_version: 4
77
+ summary: WeCom (WeChat Work) strategy for OmniAuth
78
+ test_files: []