├── .eslintignore ├── .travis.yml ├── test ├── config.js └── oauth.test.js ├── Makefile ├── .gitignore ├── package.json ├── LICENSE ├── .eslintrc ├── README.md └── lib └── oauth.js /.eslintignore: -------------------------------------------------------------------------------- 1 | *.debug.js 2 | *.min.js 3 | node_modules/* 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | script: make test-coveralls 5 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | appid: 'wxc9135aade4e81d57', 5 | appsecret: '0461795e98b8ffde5a212b5098f1b9b6' 6 | }; 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TESTS = test/*.js 2 | REPORTER = spec 3 | TIMEOUT = 20000 4 | MOCHA = ./node_modules/mocha/bin/_mocha 5 | PATH := ./node_modules/.bin:$(PATH) 6 | 7 | lint: 8 | @eslint --fix lib index.js test 9 | 10 | test: 11 | @mocha -t $(TIMEOUT) -b -R spec $(TESTS) 12 | 13 | test-cov: 14 | @nyc --reporter=html --reporter=text mocha -t $(TIMEOUT) -R spec $(TESTS) 15 | 16 | test-coveralls: lint 17 | @nyc mocha -t $(TIMEOUT) -R spec $(TESTS) 18 | @echo TRAVIS_JOB_ID $(TRAVIS_JOB_ID) 19 | @nyc report --reporter=text-lcov | coveralls 20 | 21 | .PHONY: test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | .nyc_output 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "co-wechat-oauth", 3 | "version": "2.0.1", 4 | "description": "Co 版微信公共平台 OAuth", 5 | "main": "lib/oauth.js", 6 | "scripts": { 7 | "test": "make test-all" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/node-webot/co-wechat-oauth.git" 12 | }, 13 | "keywords": [ 14 | "weixin", 15 | "wechat" 16 | ], 17 | "dependencies": { 18 | "httpx": "^2.1.1" 19 | }, 20 | "devDependencies": { 21 | "coveralls": "*", 22 | "expect.js": "*", 23 | "istanbul": "*", 24 | "kitx": "^1.2.0", 25 | "mocha": "*", 26 | "mocha-lcov-reporter": "*", 27 | "muk": "*", 28 | "nyc": "^10.3.0", 29 | "rewire": "*", 30 | "supertest": "*" 31 | }, 32 | "author": "Jackson Tian", 33 | "license": "MIT", 34 | "readmeFilename": "README.md", 35 | "directories": { 36 | "test": "test" 37 | }, 38 | "files": [ 39 | "lib" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Webot 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. 22 | 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [ 4 | 2, 5 | 2 6 | ], 7 | "quotes": [ 8 | 2, 9 | "single" 10 | ], 11 | "linebreak-style": [ 12 | 2, 13 | "unix" 14 | ], 15 | "semi": [2, "always"], 16 | "strict": [2, "global"], 17 | "curly": 2, 18 | "eqeqeq": 2, 19 | "no-eval": 2, 20 | "guard-for-in": 2, 21 | "no-caller": 2, 22 | "no-else-return": 2, 23 | "no-eq-null": 2, 24 | "no-extend-native": 2, 25 | "no-extra-bind": 2, 26 | "no-floating-decimal": 2, 27 | "no-implied-eval": 2, 28 | "no-labels": 2, 29 | "no-with": 2, 30 | "no-loop-func": 1, 31 | "no-native-reassign": 2, 32 | "no-redeclare": [2, {"builtinGlobals": true}], 33 | "no-delete-var": 2, 34 | "no-shadow-restricted-names": 2, 35 | "no-undef-init": 2, 36 | "no-use-before-define": 2, 37 | "no-unused-vars": [2, {"args": "none"}], 38 | "no-undef": 2, 39 | "callback-return": [2, ["callback", "cb", "next"]], 40 | "global-require": 0, 41 | "no-console": 0, 42 | "require-yield": 0 43 | }, 44 | "env": { 45 | "es6": true, 46 | "node": true, 47 | "browser": true 48 | }, 49 | "globals": { 50 | "describe": true, 51 | "it": true, 52 | "before": true, 53 | "after": true 54 | }, 55 | "parserOptions": { 56 | "ecmaVersion": 8, 57 | "sourceType": "script", 58 | "ecmaFeatures": { 59 | "jsx": true 60 | } 61 | }, 62 | "extends": "eslint:recommended" 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | co-wechat-oauth 2 | =============== 3 | 4 | Wechat OAuth for ES6。微信公共平台OAuth接口消息接口服务中间件与API SDK 5 | 6 | ## 模块状态 7 | 8 | - [![NPM version](https://badge.fury.io/js/co-wechat-oauth.png)](http://badge.fury.io/js/co-wechat-oauth) 9 | - [![Build Status](https://travis-ci.org/node-webot/co-wechat-oauth.png?branch=master)](https://travis-ci.org/node-webot/co-wechat-oauth) 10 | - [![Dependencies Status](https://david-dm.org/node-webot/co-wechat-oauth.png)](https://david-dm.org/node-webot/co-wechat-oauth) 11 | - [![Coverage Status](https://coveralls.io/repos/node-webot/co-wechat-oauth/badge.png)](https://coveralls.io/r/node-webot/co-wechat-oauth) 12 | 13 | ## 功能列表 14 | 15 | - OAuth授权 16 | - 获取基本信息 17 | 18 | OAuth2.0网页授权,使用此接口须通过微信认证,如果用户在微信中(Web微信除外)访问公众号的第三方网页,公众号开发者可以通过此接口获取当前用户基本信息(包括昵称、性别、城市、国家)。详见:[官方文档](http://mp.weixin.qq.com/wiki/17/c0f37d5704f0b64713d5d2c37b468d75.html) 19 | 20 | 详细参见[API文档](http://doxmate.cool/node-webot/co-wechat-oauth/api.html) 21 | 22 | ## Installation 23 | 24 | ```sh 25 | $ npm install co-wechat-oauth 26 | ``` 27 | 28 | ## Usage 29 | 30 | ### 初始化 31 | 32 | 引入 OAuth 并实例化 33 | 34 | ```js 35 | var OAuth = require('co-wechat-oauth'); 36 | var client = new OAuth('your appid', 'your secret'); 37 | ``` 38 | 39 | 以上即可满足单进程使用。 40 | 当多进程时,token 需要全局维护,以下为保存 token 的接口。 41 | 42 | ```js 43 | const util = require('util'); 44 | const fs = require('fs'); 45 | 46 | const readFile = util.promisify(fs.readFile); 47 | const writeFile = util.promisify(fs.writeFile); 48 | 49 | var oauthApi = new OAuth('appid', 'secret', async function (openid) { 50 | // 传入一个根据 openid 获取对应的全局 token 的方法 51 | var txt = await readFile(openid +':access_token.txt', 'utf8'); 52 | return JSON.parse(txt); 53 | }, async function (openid, token) { 54 | // 请将 token 存储到全局,跨进程、跨机器级别的全局,比如写到数据库、redis 等 55 | // 这样才能在 cluster 模式及多机情况下使用,以下为写入到文件的示例 56 | // 持久化时请注意,每个openid都对应一个唯一的token! 57 | await writeFile(openid + ':access_token.txt', JSON.stringify(token)); 58 | }); 59 | ``` 60 | 61 | ### 引导用户 62 | 63 | 生成引导用户点击的 URL。 64 | 65 | ```js 66 | var url = client.getAuthorizeURL('redirectUrl', 'state', 'scope'); 67 | ``` 68 | 69 | 如果是PC上的网页,请使用以下方式生成 70 | 71 | ```js 72 | var url = client.getAuthorizeURLForWebsite('redirectUrl'); 73 | ``` 74 | 75 | ### 获取 Openid 和 AccessToken 76 | 77 | 用户点击上步生成的 URL 后会被重定向到上步设置的 `redirectUrl`,并且会带有 `code` 参数,我们可以使用这个 `code` 换取 `access_token` 和用户的`openid` 78 | 79 | ```js 80 | async function () { 81 | var token = await client.getAccessToken('code'); 82 | var accessToken = token.data.access_token; 83 | var openid = token.data.openid; 84 | } 85 | ``` 86 | 87 | > 注意,因为经常会因为浏览器的后退,导致生成的地址被反复访问,这会导致 code 被反复使用而出现错误。在这一步,最佳实践是使用完 code 之后,就将用户的信息存到 session 中。再进入这个页面时,直接使用 session 中的数据。 88 | 89 | ### 获取用户信息 90 | 91 | 如果我们生成引导用户点击的 URL 中 `scope` 参数值为 `snsapi_userinfo`,接下来我们就可以使用 `openid` 换取用户详细信息(必须在 getAccessToken 方法执行完成之后) 92 | 93 | ```js 94 | async function () { 95 | var userInfo = await client.getUser('openid'); 96 | } 97 | ``` 98 | 99 | ## 捐赠 100 | 如果您觉得Wechat OAuth对您有帮助,欢迎请作者一杯咖啡 101 | 102 | ![捐赠wechat](https://cloud.githubusercontent.com/assets/327019/2941591/2b9e5e58-d9a7-11e3-9e80-c25aba0a48a1.png) 103 | 104 | 或者[![](http://img.shields.io/gratipay/JacksonTian.svg)](https://www.gittip.com/JacksonTian/) 105 | 106 | ## 交流群 107 | QQ群:157964097,使用疑问,开发,贡献代码请加群。 108 | 109 | ## Contributors 110 | 感谢以下贡献者: 111 | 112 | ``` 113 | $ git summary 114 | 115 | project : co-wechat-oauth 116 | repo age : 1 year, 8 months 117 | active : 8 days 118 | commits : 16 119 | files : 11 120 | authors : 121 | 13 Jackson Tian 81.2% 122 | 2 linkkingjay 12.5% 123 | 1 wangxiuwen 6.2% 124 | 125 | ``` 126 | 127 | ## License 128 | The MIT license. 129 | -------------------------------------------------------------------------------- /lib/oauth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const httpx = require('httpx'); 4 | 5 | const querystring = require('querystring'); 6 | 7 | class AccessToken { 8 | constructor(data) { 9 | this.data = data; 10 | } 11 | 12 | /*! 13 | * 检查AccessToken是否有效,检查规则为当前时间和过期时间进行对比 14 | * 15 | * Examples: 16 | * ``` 17 | * token.isValid(); 18 | * ``` 19 | */ 20 | isValid() { 21 | return !!this.data.access_token && (Date.now() < this.data.create_at + this.data.expires_in * 1000); 22 | } 23 | } 24 | 25 | /** 26 | * 根据appid和appsecret创建OAuth接口的构造函数 27 | * 如需跨进程跨机器进行操作,access token需要进行全局维护 28 | * 使用使用token的优先级是: 29 | * 30 | * 1. 使用当前缓存的token对象 31 | * 2. 调用开发传入的获取token的异步方法,获得token之后使用(并缓存它)。 32 | 33 | * Examples: 34 | * ``` 35 | * var OAuth = require('wechat-oauth'); 36 | * var api = new OAuth('appid', 'secret'); 37 | * ``` 38 | * @param {String} appid 在公众平台上申请得到的appid 39 | * @param {String} appsecret 在公众平台上申请得到的app secret 40 | * @param {Generator} getToken 用于获取token的方法 41 | * @param {Generator} saveToken 用于保存token的方法 42 | */ 43 | class OAuth { 44 | constructor(appid, appsecret, getToken, saveToken) { 45 | this.appid = appid; 46 | this.appsecret = appsecret; 47 | // token的获取和存储 48 | this.store = {}; 49 | this.getToken = getToken || async function (openid) { 50 | return this.store[openid]; 51 | }; 52 | if (!saveToken && process.env.NODE_ENV === 'production') { 53 | console.warn('Please dont save oauth token into memory under production'); 54 | } 55 | this.saveToken = saveToken || async function (openid, token) { 56 | this.store[openid] = token; 57 | }; 58 | this.defaults = {}; 59 | } 60 | 61 | /** 62 | * 用于设置urllib的默认options 63 | * 64 | * Examples: 65 | * ``` 66 | * oauth.setOpts({timeout: 15000}); 67 | * ``` 68 | * @param {Object} opts 默认选项 69 | */ 70 | setOpts(opts) { 71 | this.defaults = opts; 72 | } 73 | 74 | /*! 75 | * urllib的封装 76 | * 77 | * @param {String} url 路径 78 | * @param {Object} opts urllib选项 79 | */ 80 | async request(url, opts = {}) { 81 | var options = Object.assign({}, this.defaults); 82 | for (var key in opts) { 83 | if (key !== 'headers') { 84 | options[key] = opts[key]; 85 | } else { 86 | if (opts.headers) { 87 | options.headers = options.headers || {}; 88 | Object.assign(options.headers, opts.headers); 89 | } 90 | } 91 | } 92 | 93 | var data; 94 | try { 95 | var response = await httpx.request(url, options); 96 | var text = await httpx.read(response, 'utf8'); 97 | data = JSON.parse(text); 98 | } catch (err) { 99 | err.name = 'WeChatAPI' + err.name; 100 | throw err; 101 | } 102 | 103 | if (data.errcode) { 104 | var err = new Error(data.errmsg); 105 | err.name = 'WeChatAPIError'; 106 | err.code = data.errcode; 107 | throw err; 108 | } 109 | 110 | return data; 111 | } 112 | 113 | /** 114 | * 获取授权页面的URL地址 115 | * @param {String} redirect 授权后要跳转的地址 116 | * @param {String} state 开发者可提供的数据 117 | * @param {String} scope 作用范围,值为snsapi_userinfo和snsapi_base,前者用于弹出,后者用于跳转 118 | */ 119 | getAuthorizeURL(redirect, state, scope) { 120 | var url = 'https://open.weixin.qq.com/connect/oauth2/authorize'; 121 | var info = { 122 | appid: this.appid, 123 | redirect_uri: redirect, 124 | response_type: 'code', 125 | scope: scope || 'snsapi_base', 126 | state: state || '' 127 | }; 128 | 129 | return url + '?' + querystring.stringify(info) + '#wechat_redirect'; 130 | } 131 | 132 | /** 133 | * 获取授权页面的URL地址 134 | * @param {String} redirect 授权后要跳转的地址 135 | * @param {String} state 开发者可提供的数据 136 | * @param {String} scope 作用范围,值为snsapi_login,前者用于弹出,后者用于跳转 137 | */ 138 | getAuthorizeURLForWebsite(redirect, state, scope) { 139 | var url = 'https://open.weixin.qq.com/connect/qrconnect'; 140 | var info = { 141 | appid: this.appid, 142 | redirect_uri: redirect, 143 | response_type: 'code', 144 | scope: scope || 'snsapi_login', 145 | state: state || '' 146 | }; 147 | 148 | return url + '?' + querystring.stringify(info) + '#wechat_redirect'; 149 | } 150 | 151 | /*! 152 | * 处理token,更新过期时间 153 | */ 154 | async processToken(data) { 155 | data.create_at = Date.now(); 156 | // 存储token 157 | await this.saveToken(data.openid, data); 158 | return new AccessToken(data); 159 | } 160 | 161 | /** 162 | * 根据授权获取到的code,换取access token和openid 163 | * 获取openid之后,可以调用`wechat.API`来获取更多信息 164 | * Examples: 165 | * ``` 166 | * await api.getAccessToken(code); 167 | * ``` 168 | * Exception: 169 | * 170 | * - `err`, 获取access token出现异常时的异常对象 171 | * 172 | * 返回值: 173 | * ``` 174 | * { 175 | * data: { 176 | * "access_token": "ACCESS_TOKEN", 177 | * "expires_in": 7200, 178 | * "refresh_token": "REFRESH_TOKEN", 179 | * "openid": "OPENID", 180 | * "scope": "SCOPE" 181 | * } 182 | * } 183 | * ``` 184 | * @param {String} code 授权获取到的code 185 | */ 186 | async getAccessToken(code) { 187 | var info = { 188 | appid: this.appid, 189 | secret: this.appsecret, 190 | code: code, 191 | grant_type: 'authorization_code' 192 | }; 193 | 194 | var url = `https://api.weixin.qq.com/sns/oauth2/access_token?${querystring.stringify(info)}`; 195 | var data = await this.request(url, { 196 | headers: { 197 | accept: 'application/json' 198 | } 199 | }); 200 | 201 | return this.processToken(data); 202 | } 203 | 204 | /** 205 | * 根据refresh token,刷新access token,调用getAccessToken后才有效 206 | * Examples: 207 | * ``` 208 | * api.refreshAccessToken(refreshToken); 209 | * ``` 210 | * Exception: 211 | * 212 | * - `err`, 刷新access token出现异常时的异常对象 213 | * 214 | * Return: 215 | * ``` 216 | * { 217 | * data: { 218 | * "access_token": "ACCESS_TOKEN", 219 | * "expires_in": 7200, 220 | * "refresh_token": "REFRESH_TOKEN", 221 | * "openid": "OPENID", 222 | * "scope": "SCOPE" 223 | * } 224 | * } 225 | * ``` 226 | * @param {String} refreshToken refreshToken 227 | */ 228 | async refreshAccessToken(refreshToken) { 229 | var url = 'https://api.weixin.qq.com/sns/oauth2/refresh_token'; 230 | var info = { 231 | appid: this.appid, 232 | grant_type: 'refresh_token', 233 | refresh_token: refreshToken 234 | }; 235 | var url = `https://api.weixin.qq.com/sns/oauth2/refresh_token?${querystring.stringify(info)}`; 236 | 237 | var data = await this.request(url, { 238 | headers: { 239 | accept: 'application/json' 240 | } 241 | }); 242 | 243 | return this.processToken(data); 244 | } 245 | 246 | _getUser(options, accessToken) { 247 | var url = 'https://api.weixin.qq.com/sns/userinfo'; 248 | var info = { 249 | access_token: accessToken, 250 | openid: options.openid, 251 | lang: options.lang || 'en' 252 | }; 253 | var args = { 254 | data: info, 255 | dataType: 'json' 256 | }; 257 | var url = `https://api.weixin.qq.com/sns/userinfo?${querystring.stringify(info)}`; 258 | return this.request(url, { 259 | headers: { 260 | accept: 'application/json' 261 | } 262 | }); 263 | } 264 | 265 | /** 266 | * 根据openid,获取用户信息。 267 | * 当access token无效时,自动通过refresh token获取新的access token。然后再获取用户信息 268 | * Examples: 269 | * ``` 270 | * api.getUser(options); 271 | * ``` 272 | * 273 | * Options: 274 | * ``` 275 | * openId 276 | * // 或 277 | * { 278 | * "openId": "the open Id", // 必须 279 | * "lang": "the lang code" // zh_CN 简体,zh_TW 繁体,en 英语 280 | * } 281 | * ``` 282 | * Callback: 283 | * 284 | * - `err`, 获取用户信息出现异常时的异常对象 285 | * 286 | * Result: 287 | * ``` 288 | * { 289 | * "openid": "OPENID", 290 | * "nickname": "NICKNAME", 291 | * "sex": "1", 292 | * "province": "PROVINCE" 293 | * "city": "CITY", 294 | * "country": "COUNTRY", 295 | * "headimgurl": "http://wx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46", 296 | * "privilege": [ 297 | * "PRIVILEGE1" 298 | * "PRIVILEGE2" 299 | * ] 300 | * } 301 | * ``` 302 | * @param {Object|String} options 传入openid或者参见Options 303 | */ 304 | async getUser(options) { 305 | if (typeof options !== 'object') { 306 | options = { 307 | openid: options 308 | }; 309 | } 310 | 311 | var data = await this.getToken(options.openid); 312 | 313 | // 没有token数据 314 | if (!data) { 315 | var error = new Error('No token for ' + options.openid + ', please authorize first.'); 316 | error.name = 'NoOAuthTokenError'; 317 | throw error; 318 | } 319 | var token = new AccessToken(data); 320 | var accessToken; 321 | if (token.isValid()) { 322 | accessToken = token.data.access_token; 323 | } else { 324 | var newToken = await this.refreshAccessToken(token.data.refresh_token); 325 | accessToken = newToken.data.access_token; 326 | } 327 | 328 | return this._getUser(options, accessToken); 329 | } 330 | 331 | verifyToken(openid, accessToken) { 332 | var info = { 333 | access_token: accessToken, 334 | openid: openid 335 | }; 336 | 337 | var url = `https://api.weixin.qq.com/sns/auth?${querystring.stringify(info)}`; 338 | return this.request(url, { 339 | headers: { 340 | 'content-type': 'application/json' 341 | } 342 | }); 343 | } 344 | 345 | /** 346 | * 根据code,获取用户信息。 347 | * Examples: 348 | * ``` 349 | * var user = await api.getUserByCode(code); 350 | * ``` 351 | * Exception: 352 | * 353 | * - `err`, 获取用户信息出现异常时的异常对象 354 | * 355 | * Result: 356 | * ``` 357 | * { 358 | * "openid": "OPENID", 359 | * "nickname": "NICKNAME", 360 | * "sex": "1", 361 | * "province": "PROVINCE" 362 | * "city": "CITY", 363 | * "country": "COUNTRY", 364 | * "headimgurl": "http://wx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46", 365 | * "privilege": [ 366 | * "PRIVILEGE1" 367 | * "PRIVILEGE2" 368 | * ] 369 | * } 370 | * ``` 371 | * @param {String} code 授权获取到的code 372 | */ 373 | async getUserByCode(code) { 374 | var token = await this.getAccessToken(code); 375 | return this.getUser(token.data.openid); 376 | } 377 | } 378 | 379 | module.exports = OAuth; 380 | -------------------------------------------------------------------------------- /test/oauth.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('expect.js'); 4 | const muk = require('muk'); 5 | const kitx = require('kitx'); 6 | const httpx = require('httpx'); 7 | const OAuth = require('../'); 8 | const config = require('./config'); 9 | 10 | describe('oauth.js', function () { 11 | describe('getAuthorizeURL', function () { 12 | var auth = new OAuth('appid', 'appsecret'); 13 | it('should ok', function () { 14 | var url = auth.getAuthorizeURL('http://diveintonode.org/'); 15 | expect(url).to.be.equal('https://open.weixin.qq.com/connect/oauth2/authorize?appid=appid&redirect_uri=http%3A%2F%2Fdiveintonode.org%2F&response_type=code&scope=snsapi_base&state=#wechat_redirect'); 16 | }); 17 | 18 | it('should ok with state', function () { 19 | var url = auth.getAuthorizeURL('http://diveintonode.org/', 'hehe'); 20 | expect(url).to.be.equal('https://open.weixin.qq.com/connect/oauth2/authorize?appid=appid&redirect_uri=http%3A%2F%2Fdiveintonode.org%2F&response_type=code&scope=snsapi_base&state=hehe#wechat_redirect'); 21 | }); 22 | 23 | it('should ok with state and scope', function () { 24 | var url = auth.getAuthorizeURL('http://diveintonode.org/', 'hehe', 'snsapi_userinfo'); 25 | expect(url).to.be.equal('https://open.weixin.qq.com/connect/oauth2/authorize?appid=appid&redirect_uri=http%3A%2F%2Fdiveintonode.org%2F&response_type=code&scope=snsapi_userinfo&state=hehe#wechat_redirect'); 26 | }); 27 | }); 28 | 29 | describe('getAuthorizeURLForWebsite', function () { 30 | var auth = new OAuth('appid', 'appsecret'); 31 | it('should ok', function () { 32 | var url = auth.getAuthorizeURLForWebsite('http://diveintonode.org/'); 33 | expect(url).to.be.equal('https://open.weixin.qq.com/connect/qrconnect?appid=appid&redirect_uri=http%3A%2F%2Fdiveintonode.org%2F&response_type=code&scope=snsapi_login&state=#wechat_redirect'); 34 | }); 35 | 36 | it('should ok with state', function () { 37 | var url = auth.getAuthorizeURLForWebsite('http://diveintonode.org/', 'hehe'); 38 | expect(url).to.be.equal('https://open.weixin.qq.com/connect/qrconnect?appid=appid&redirect_uri=http%3A%2F%2Fdiveintonode.org%2F&response_type=code&scope=snsapi_login&state=hehe#wechat_redirect'); 39 | }); 40 | 41 | it('should ok with state and scope', function () { 42 | var url = auth.getAuthorizeURLForWebsite('http://diveintonode.org/', 'hehe', 'snsapi_userinfo'); 43 | expect(url).to.be.equal('https://open.weixin.qq.com/connect/qrconnect?appid=appid&redirect_uri=http%3A%2F%2Fdiveintonode.org%2F&response_type=code&scope=snsapi_userinfo&state=hehe#wechat_redirect'); 44 | }); 45 | }); 46 | 47 | describe('getAccessToken', function () { 48 | var api = new OAuth(config.appid, config.appsecret); 49 | it('should invalid', async function () { 50 | try { 51 | await api.getAccessToken('code'); 52 | } catch (err) { 53 | expect(err).to.be.ok(); 54 | expect(err.name).to.be.equal('WeChatAPIError'); 55 | expect(err.message).to.contain('invalid code'); 56 | return; 57 | } 58 | // should never be executed 59 | expect(false).to.be.ok(); 60 | }); 61 | 62 | describe('should ok', function () { 63 | before(function () { 64 | muk(httpx, 'request', async function (url, opts) { 65 | return { 66 | headers: {} 67 | }; 68 | }); 69 | 70 | muk(httpx, 'read', async function (response, encoding) { 71 | return JSON.stringify({ 72 | 'access_token':'ACCESS_TOKEN', 73 | 'expires_in':7200, 74 | 'refresh_token':'REFRESH_TOKEN', 75 | 'openid':'OPENID', 76 | 'scope':'SCOPE' 77 | }); 78 | }); 79 | }); 80 | 81 | after(function () { 82 | muk.restore(); 83 | }); 84 | 85 | it('should ok', async function () { 86 | var token = await api.getAccessToken('code'); 87 | expect(token).to.have.property('data'); 88 | expect(token.data).to.have.keys('access_token', 'expires_in', 'refresh_token', 'openid', 'scope', 'create_at'); 89 | }); 90 | }); 91 | 92 | describe('should not ok', function () { 93 | before(function () { 94 | muk(httpx, 'request', async function (url, opts) { 95 | return { 96 | headers: {} 97 | }; 98 | }); 99 | 100 | muk(httpx, 'read', async function (response, encoding) { 101 | return JSON.stringify({ 102 | 'access_token':'ACCESS_TOKEN', 103 | 'expires_in': 0.1, 104 | 'refresh_token':'REFRESH_TOKEN', 105 | 'openid':'OPENID', 106 | 'scope':'SCOPE' 107 | }); 108 | }); 109 | }); 110 | 111 | after(function () { 112 | muk.restore(); 113 | }); 114 | 115 | it('should not ok', async function () { 116 | var token = await api.getAccessToken('code'); 117 | await kitx.sleep(200); 118 | expect(token.isValid()).not.to.be.ok(); 119 | }); 120 | }); 121 | }); 122 | 123 | describe('refreshAccessToken', function () { 124 | var api = new OAuth('appid', 'secret'); 125 | 126 | it('should invalid', async function () { 127 | try { 128 | await api.refreshAccessToken('refresh_token'); 129 | } catch (err) { 130 | expect(err).to.be.ok(); 131 | expect(err.name).to.be.equal('WeChatAPIError'); 132 | expect(err.message).to.contain('invalid appid'); 133 | return; 134 | } 135 | // should never be executed 136 | expect(false).to.be.ok(); 137 | }); 138 | 139 | describe('should ok', function () { 140 | before(function () { 141 | muk(httpx, 'request', async function (url, opts) { 142 | return { 143 | headers: {} 144 | }; 145 | }); 146 | 147 | muk(httpx, 'read', async function (response, encoding) { 148 | return JSON.stringify({ 149 | 'access_token':'ACCESS_TOKEN', 150 | 'expires_in':7200, 151 | 'refresh_token':'REFRESH_TOKEN', 152 | 'openid':'OPENID', 153 | 'scope':'SCOPE' 154 | }); 155 | }); 156 | }); 157 | 158 | after(function () { 159 | muk.restore(); 160 | }); 161 | 162 | it('should ok', async function () { 163 | var token = await api.refreshAccessToken('refresh_token'); 164 | expect(token.data).to.have.keys('access_token', 'expires_in', 'refresh_token', 'openid', 'scope', 'create_at'); 165 | }); 166 | }); 167 | }); 168 | 169 | describe('_getUser', function () { 170 | it('should invalid', async function () { 171 | try { 172 | var api = new OAuth('appid', 'secret'); 173 | await api._getUser('openid', 'access_token'); 174 | } catch (err) { 175 | expect(err).to.be.ok(); 176 | expect(err.name).to.be.equal('WeChatAPIError'); 177 | expect(err.message).to.contain('invalid credential, access_token is invalid or not latest'); 178 | return; 179 | } 180 | // should never be executed 181 | expect(false).to.be.ok(); 182 | }); 183 | 184 | describe('mock get user ok', function () { 185 | var api = new OAuth('appid', 'secret'); 186 | before(function () { 187 | muk(httpx, 'request', async function (url, opts) { 188 | return { 189 | headers: {} 190 | }; 191 | }); 192 | 193 | muk(httpx, 'read', async function (response, encoding) { 194 | return JSON.stringify({ 195 | 'openid': 'OPENID', 196 | 'nickname': 'NICKNAME', 197 | 'sex': '1', 198 | 'province': 'PROVINCE', 199 | 'city': 'CITY', 200 | 'country': 'COUNTRY', 201 | 'headimgurl': 'http://wx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46', 202 | 'privilege': [ 203 | 'PRIVILEGE1', 204 | 'PRIVILEGE2' 205 | ] 206 | }); 207 | }); 208 | }); 209 | 210 | after(function () { 211 | muk.restore(); 212 | }); 213 | 214 | it('should ok', async function () { 215 | var data = await api._getUser('openid', 'access_token'); 216 | expect(data).to.have.keys('openid', 'nickname', 'sex', 'province', 'city', 217 | 'country', 'headimgurl', 'privilege'); 218 | }); 219 | }); 220 | }); 221 | 222 | describe('getUser', function () { 223 | it('can not get token', async function () { 224 | var api = new OAuth('appid', 'secret'); 225 | try { 226 | await api.getUser('openid'); 227 | } catch (err) { 228 | expect(err).to.be.ok(); 229 | expect(err.message).to.be.equal('No token for openid, please authorize first.'); 230 | return; 231 | } 232 | // should never be executed 233 | expect(false).to.be.ok(); 234 | }); 235 | 236 | describe('mock get token error', function () { 237 | var api = new OAuth('appid', 'secret'); 238 | before(function () { 239 | muk(api, 'getToken', async function (openid) { 240 | throw new Error('get token error'); 241 | }); 242 | }); 243 | 244 | after(function () { 245 | muk.restore(); 246 | }); 247 | 248 | it('should ok', async function () { 249 | try { 250 | await api.getUser('openid'); 251 | } catch (err) { 252 | expect(err).to.be.ok(); 253 | expect(err.message).to.be.equal('get token error'); 254 | return; 255 | } 256 | // should never be executed 257 | expect(false).to.be.ok(); 258 | }); 259 | }); 260 | 261 | describe('mock get null data', function () { 262 | var api = new OAuth('appid', 'secret'); 263 | before(function () { 264 | muk(api, 'getToken', async function (openid) { 265 | return null; 266 | }); 267 | }); 268 | 269 | after(function () { 270 | muk.restore(); 271 | }); 272 | 273 | it('should ok', async function () { 274 | try { 275 | await api.getUser('openid'); 276 | } catch (err) { 277 | expect(err).to.be.ok(); 278 | expect(err).to.have.property('name', 'NoOAuthTokenError'); 279 | expect(err).to.have.property('message', 'No token for openid, please authorize first.'); 280 | return; 281 | } 282 | // should never be executed 283 | expect(false).to.be.ok(); 284 | }); 285 | }); 286 | 287 | describe('mock get valid token', function () { 288 | var api = new OAuth('appid', 'secret'); 289 | before(function () { 290 | muk(api, 'getToken', async function (openid) { 291 | return { 292 | access_token: 'access_token', 293 | create_at: new Date().getTime(), 294 | expires_in: 60 295 | }; 296 | }); 297 | 298 | muk(api, '_getUser', async function (openid, accessToken) { 299 | return { 300 | 'openid': 'OPENID', 301 | 'nickname': 'NICKNAME', 302 | 'sex': '1', 303 | 'province': 'PROVINCE', 304 | 'city': 'CITY', 305 | 'country': 'COUNTRY', 306 | 'headimgurl': 'http://wx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46', 307 | 'privilege': [ 308 | 'PRIVILEGE1', 309 | 'PRIVILEGE2' 310 | ] 311 | }; 312 | }); 313 | }); 314 | 315 | after(function () { 316 | muk.restore(); 317 | }); 318 | 319 | it('should ok with openid', async function () { 320 | var data = await api.getUser('openid'); 321 | expect(data).to.have.keys('openid', 'nickname', 'sex', 'province', 'city', 322 | 'country', 'headimgurl', 'privilege'); 323 | }); 324 | 325 | it('should ok with options', async function () { 326 | var data = await api.getUser({openid: 'openid', lang: 'en'}); 327 | expect(data).to.have.keys('openid', 'nickname', 'sex', 'province', 'city', 328 | 'country', 'headimgurl', 'privilege'); 329 | }); 330 | 331 | it('should ok with options', async function () { 332 | var data = await api.getUser({openid: 'openid'}); 333 | expect(data).to.have.keys('openid', 'nickname', 'sex', 'province', 'city', 334 | 'country', 'headimgurl', 'privilege'); 335 | }); 336 | }); 337 | 338 | describe('mock get invalid token', function () { 339 | var api = new OAuth('appid', 'secret'); 340 | before(function () { 341 | muk(api, 'getToken', async function (openid) { 342 | return { 343 | access_token: 'access_token', 344 | create_at: new Date().getTime() - 70 * 1000, 345 | expires_in: 60 346 | }; 347 | }); 348 | }); 349 | 350 | after(function () { 351 | muk.restore(); 352 | }); 353 | 354 | it('should ok', async function () { 355 | try { 356 | await api.getUser('openid'); 357 | } catch (err) { 358 | expect(err).to.be.ok(); 359 | expect(err).to.have.property('name', 'WeChatAPIError'); 360 | expect(err.message).to.contain('refresh_token missing'); 361 | return; 362 | } 363 | // should never be executed 364 | expect(false).to.be.ok(); 365 | }); 366 | }); 367 | 368 | describe('mock get invalid token and refresh_token', function () { 369 | var api = new OAuth('appid', 'secret'); 370 | before(function () { 371 | muk(api, 'getToken', async function (openid) { 372 | return { 373 | access_token: 'access_token', 374 | refresh_token: 'refresh_token', 375 | create_at: new Date().getTime() - 70 * 1000, 376 | expires_in: 60 377 | }; 378 | }); 379 | 380 | muk(api, 'refreshAccessToken', async function (refreshToken) { 381 | return { 382 | data: { 383 | 'access_token': 'ACCESS_TOKEN', 384 | 'expires_in': 7200, 385 | 'refresh_token': 'REFRESH_TOKEN', 386 | 'openid': 'OPENID', 387 | 'scope': 'SCOPE' 388 | } 389 | }; 390 | }); 391 | 392 | muk(api, '_getUser', async function (openid, accessToken) { 393 | return { 394 | 'openid': 'OPENID', 395 | 'nickname': 'NICKNAME', 396 | 'sex': '1', 397 | 'province': 'PROVINCE', 398 | 'city': 'CITY', 399 | 'country': 'COUNTRY', 400 | 'headimgurl': 'http://wx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46', 401 | 'privilege': [ 402 | 'PRIVILEGE1', 403 | 'PRIVILEGE2' 404 | ] 405 | }; 406 | }); 407 | }); 408 | 409 | after(function () { 410 | muk.restore(); 411 | }); 412 | 413 | it('should ok', async function () { 414 | var data = await api.getUser('openid'); 415 | expect(data).to.have.keys('openid', 'nickname', 'sex', 'province', 'city', 'country', 'headimgurl', 'privilege'); 416 | }); 417 | }); 418 | }); 419 | 420 | describe('mock getUserByCode', function () { 421 | var api = new OAuth('appid', 'secret'); 422 | before(function () { 423 | muk(httpx, 'request', async function (url, opts) { 424 | return { 425 | headers: {} 426 | }; 427 | }); 428 | 429 | muk(httpx, 'read', async function (response, encoding) { 430 | return JSON.stringify({ 431 | 'access_token':'ACCESS_TOKEN', 432 | 'expires_in':7200, 433 | 'refresh_token':'REFRESH_TOKEN', 434 | 'openid':'OPENID', 435 | 'scope':'SCOPE' 436 | }); 437 | }); 438 | 439 | muk(api, '_getUser', function (openid, accessToken) { 440 | return { 441 | 'openid': 'OPENID', 442 | 'nickname': 'NICKNAME', 443 | 'sex': '1', 444 | 'province': 'PROVINCE', 445 | 'city': 'CITY', 446 | 'country': 'COUNTRY', 447 | 'headimgurl': 'http://wx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46', 448 | 'privilege': [ 449 | 'PRIVILEGE1', 450 | 'PRIVILEGE2' 451 | ] 452 | }; 453 | }); 454 | }); 455 | 456 | after(function () { 457 | muk.restore(); 458 | }); 459 | 460 | it('should ok with getUserByCode', async function () { 461 | var data = await api.getUserByCode('code'); 462 | expect(data).to.have.keys('openid', 'nickname', 'sex', 'province', 'city', 463 | 'country', 'headimgurl', 'privilege'); 464 | }); 465 | 466 | it('should ok with getUserByCode', async function () { 467 | var options = {code: 'code', lang: 'en'}; 468 | var data = await api.getUserByCode(options); 469 | expect(data).to.have.keys('openid', 'nickname', 'sex', 'province', 'city', 470 | 'country', 'headimgurl', 'privilege'); 471 | }); 472 | }); 473 | 474 | describe('verifyToken', function () { 475 | var api = new OAuth('appid', 'secret'); 476 | it('should ok with verifyToken', async function () { 477 | try { 478 | api.verifyToken('openid', 'access_token'); 479 | } catch (err) { 480 | var result = 481 | expect(err).to.be.ok(); 482 | expect(err.message).to.contain('access_token is invalid'); 483 | } 484 | }); 485 | }); 486 | }); 487 | --------------------------------------------------------------------------------