diff --git a/.eslintrc.js b/.eslintrc.js index fb678f60f..bf6200ebf 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -28,11 +28,12 @@ module.exports = { 'error', 'never' ], + "indent": [ "error", "tab", { "SwitchCase": 1 } ], // `eslint/no-unused-vars` will check all qualified ts files, include d.ts // using interface to define function types is compliant, but `eslint/no-unused-vars` will prompt for unused parameters...... // so set `args === none` here // and leave `no-unused-vars` to `@typescript-eslint/no-unused-vars` - "no-unused-vars": ["error", { "vars": "all", "args": "none", "ignoreRestSiblings": false }], + "no-unused-vars": ["error", { "vars": "all", "args": "none", "ignoreRestSiblings": false, varsIgnorePattern: '.*' }], "@typescript-eslint/no-unused-vars": ['error'], '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-var-requires': 'off', diff --git a/packages/miniapp/src/Authing.ts b/packages/miniapp/src/Authing.ts index 5bb926a02..e5daf45c9 100644 --- a/packages/miniapp/src/Authing.ts +++ b/packages/miniapp/src/Authing.ts @@ -1,567 +1,583 @@ import { - IStorageProvider, - EncryptFunction, - LoginState, - EncryptType, - AuthingOptions, - RefreshTokenOptions, - WxCodeLoginOptions, - WxPhoneLoginOptions, - PasswordLoginOptions, - PassCodeLoginOptions, - SendSmsOptions, - NormalResponseData, - GetPhoneOptions, - GetUserPhoneResponseData, - UserInfo, - UpdatePasswordOptions, - UploadFileResponseData, - LoginByCodeOptions, - Maybe, - UpdateUserInfo + IStorageProvider, + EncryptFunction, + LoginState, + EncryptType, + AuthingOptions, + RefreshTokenOptions, + WxCodeLoginOptions, + WxPhoneLoginOptions, + PasswordLoginOptions, + PassCodeLoginOptions, + SendSmsOptions, + GetPhoneOptions, + GetUserPhoneResponseData, + UserInfo, + UpdatePasswordOptions, + UploadFileResponseData, + LoginByCodeOptions, + SDKResponse, + UpdateUserInfo, + SimpleResponseData } from './types' -import { error, getLoginStateKey, getWxLoginCodeKey, request, StorageProvider } from './helpers' +import { returnSuccess, returnError } from './helpers/return' + +import { getLoginStateKey, getWxLoginCodeKey, request, StorageProvider } from './helpers' import { AuthingMove } from './AuthingMove' export class Authing { - private storage: IStorageProvider - private readonly options: AuthingOptions - private readonly encryptFunction?: EncryptFunction + private storage: IStorageProvider + private readonly options: AuthingOptions + private readonly encryptFunction?: EncryptFunction - constructor(options: AuthingOptions) { - this.options = { - ...options, - host: options.host || 'https://core.authing.cn' - } + constructor(options: AuthingOptions) { + this.options = { + ...options, + host: options.host || 'https://core.authing.cn' + } - this.storage = new StorageProvider() + this.storage = new StorageProvider() - this.encryptFunction = options.encryptFunction + this.encryptFunction = options.encryptFunction - this.resetWxLoginCode() - } + this.resetWxLoginCode() + } - async getLoginState(): Promise { - try { - const res = await this.storage.get( - getLoginStateKey(this.options.appId) - ) + async getLoginState(): Promise> { + try { + const res = await this.storage.get( + getLoginStateKey(this.options.appId) + ) - const loginState = res.data - - if (loginState.expires_at > Date.now()) { - return loginState - } - - error('getLoginState', 'loginState has expired, please login again') - - return null - } catch (e) { - return null - } - } - - async clearLoginState(): Promise { - try { - await this.storage.remove( - getLoginStateKey(this.options.appId) - ) - return true - } catch (e) { - error('clearLoginState', e) - return false - } - } - - private async saveLoginState( - loginState: LoginState - ): Promise { - const _loginState: LoginState = { - ...loginState, - expires_at: loginState.expires_in * 1000 + Date.now() - 3600 * 1000 * 2 - } - - await this.storage.set( - getLoginStateKey(this.options.appId), - _loginState - ) - - return _loginState - } - - async getPublicKey(encryptType: EncryptType): Promise { - try { - const res = await request({ - method: 'GET', - url: `${this.options.host}/api/v3/system` - }) + const loginState: LoginState = res.data + + if (loginState.expires_at > Date.now()) { + return returnSuccess(loginState) + } + + return returnError({ + message: 'login state has expired, please login again' + }) + } catch (e) { + return returnError({ + message: JSON.stringify(e) + }) + } + } + + async clearLoginState(): Promise> { + try { + await this.storage.remove( + getLoginStateKey(this.options.appId) + ) + return returnSuccess(true) + } catch (e) { + return returnError({ + message: JSON.stringify(e) + }) + } + } + + private async saveLoginState( + loginState: LoginState + ): Promise { + const _loginState: LoginState = { + ...loginState, + expires_at: loginState.expires_in * 1000 + Date.now() - 3600 * 1000 * 2 + } + + await this.storage.set( + getLoginStateKey(this.options.appId), + _loginState + ) + + return _loginState + } + + async getPublicKey(encryptType: EncryptType): Promise> { + try { + const [error, res] = await request({ + method: 'GET', + url: `${this.options.host}/api/v3/system` + }) + + if (error) { + return returnError(error) + } - return res[encryptType].publicKey - } catch (e) { - error('getPublicKey', e) - return '' - } - } - - private async getCachedWxLoginCode (): Promise { - try { - const res = await this.storage.get(getWxLoginCodeKey(this.options.appId)) - return res.data - } catch (e) { - return '' - } - } - - private async cacheWxLoginCode (code: string): Promise { - try { - await this.storage.set(getWxLoginCodeKey(this.options.appId), code) - return code - } catch (e) { - error('cacheWxLoginCode', e) - return '' - } - } - - private async resetWxLoginCode (): Promise { - const next = async () => { - try { - const wxLoginRes = await AuthingMove.login() - await this.cacheWxLoginCode(wxLoginRes.code) - } catch (e) { - error('resetWxLoginCode', e) - } - } - - try { - await AuthingMove.checkSession() - const code = await this.getCachedWxLoginCode() - if (!code) { - await next() - } - } catch (e) { - this.storage.remove(getWxLoginCodeKey(this.options.appId)) - await next() - } finally { - return await this.getCachedWxLoginCode() - } - } - - async loginByCode( - data: LoginByCodeOptions - ): Promise> { - const loginState = await this.getLoginState() - - if (loginState && loginState.expires_at > Date.now()) { - return loginState - } - - const { extIdpConnidentifier, connection, wechatMiniProgramCodePayload, options } = data - - const code = await this.resetWxLoginCode() - - if (!code) { - error('loginByCode', 'get wx login code error') - return null - } - - const _data: WxCodeLoginOptions = { - connection: connection || 'wechat_mini_program_code', - extIdpConnidentifier, - wechatMiniProgramCodePayload: { - ...wechatMiniProgramCodePayload, - code - }, - options - } - - return await this.login(_data, 'code') - } - - // async loginByPhone( - // data: LoginByPhoneOptions - // ): Promise> { - // const loginState = await this.getLoginState() - - // if (loginState && loginState.expires_at > Date.now()) { - // return loginState - // } - - // const { extIdpConnidentifier, connection, wechatMiniProgramPhonePayload, options } = data - - // const code = await this.resetWxLoginCode() - - // if (!code) { - // error('loginByPhone', 'get wx login code error') - // return null - // } - - // const _data: WxPhoneLoginOptions = { - // connection: connection || 'wechat_mini_program_phone', - // extIdpConnidentifier, - // wechatMiniProgramPhonePayload: { - // ...wechatMiniProgramPhonePayload, - // code - // }, - // options - // } - - // return await this.login(_data, 'phone') - // } - - async loginByPassword( - data: PasswordLoginOptions - ): Promise> { - if ( - data.options?.passwordEncryptType && + return returnSuccess(res[encryptType].publicKey) + } catch (e) { + return returnError({ + message: 'get public key error: ' + JSON.stringify(e) + }) + } + } + + private async getCachedWxLoginCode (): Promise { + try { + const res = await this.storage.get(getWxLoginCodeKey(this.options.appId)) + return res.data + } catch (e) { + return '' + } + } + + private async cacheWxLoginCode (code: string): Promise { + try { + await this.storage.set(getWxLoginCodeKey(this.options.appId), code) + return code + } catch (e) { + return '' + } + } + + private async resetWxLoginCode (): Promise { + const next = async () => { + try { + const wxLoginRes = await AuthingMove.login() + await this.cacheWxLoginCode(wxLoginRes.code) + return true + } catch (e) { + return false + } + } + + try { + await AuthingMove.checkSession() + const code = await this.getCachedWxLoginCode() + if (!code) { + await next() + } + } catch (e) { + this.storage.remove(getWxLoginCodeKey(this.options.appId)) + await next() + } finally { + return await this.getCachedWxLoginCode() + } + } + + async loginByCode( + data: LoginByCodeOptions + ): Promise> { + const [, loginState] = await this.getLoginState() + + if (loginState && loginState.expires_at > Date.now()) { + return returnSuccess(loginState) + } + + const { extIdpConnidentifier, connection, wechatMiniProgramCodePayload, options } = data + + const code = await this.resetWxLoginCode() + + if (!code) { + return returnError({ + message: 'get wx login code error' + }) + } + + const _data: WxCodeLoginOptions = { + connection: connection || 'wechat_mini_program_code', + extIdpConnidentifier, + wechatMiniProgramCodePayload: { + ...wechatMiniProgramCodePayload, + code + }, + options + } + + return await this.login(_data, 'code') + } + + // async loginByPhone( + // data: LoginByPhoneOptions + // ): Promise> { + // const loginState = await this.getLoginState() + + // if (loginState && loginState.expires_at > Date.now()) { + // return loginState + // } + + // const { extIdpConnidentifier, connection, wechatMiniProgramPhonePayload, options } = data + + // const code = await this.resetWxLoginCode() + + // if (!code) { + // error('loginByPhone', 'get wx login code error') + // return null + // } + + // const _data: WxPhoneLoginOptions = { + // connection: connection || 'wechat_mini_program_phone', + // extIdpConnidentifier, + // wechatMiniProgramPhonePayload: { + // ...wechatMiniProgramPhonePayload, + // code + // }, + // options + // } + + // return await this.login(_data, 'phone') + // } + + async loginByPassword( + data: PasswordLoginOptions + ): Promise> { + if ( + data.options?.passwordEncryptType && data.options?.passwordEncryptType !== 'none' - ) { - if (!this.encryptFunction) { - error( - 'loginByPassword', - 'encryptFunction is required, if passwordEncryptType is not "none"' - ) - return null - } - - const publicKey = await this.getPublicKey( - data.options?.passwordEncryptType - ) - - if (!publicKey) { - error('loginByPassword', 'publicKey is invalid') - return null - } - - data.passwordPayload.password = this.encryptFunction( - data.passwordPayload.password, - publicKey - ) - } - - const _data: PasswordLoginOptions = { - ...data, - connection: 'PASSWORD' - } - - return await this.login(_data, 'password') - } - - async loginByPassCode( - data: PassCodeLoginOptions - ): Promise> { - if (data.passCodePayload.phone) { - data.passCodePayload.phoneCountryCode = + ) { + if (!this.encryptFunction) { + return returnError({ + message: 'encryptFunction is required, if passwordEncryptType is not "none"' + }) + } + + const [error, publicKey] = await this.getPublicKey( + data.options?.passwordEncryptType + ) + + if (error) { + return returnError(error) + } + + data.passwordPayload.password = this.encryptFunction( + data.passwordPayload.password, + publicKey as string + ) + } + + const _data: PasswordLoginOptions = { + ...data, + connection: 'PASSWORD' + } + + return await this.login(_data, 'password') + } + + async loginByPassCode( + data: PassCodeLoginOptions + ): Promise> { + if (data.passCodePayload.phone) { + data.passCodePayload.phoneCountryCode = data.passCodePayload.phoneCountryCode || '+86' - } - - const _data: PassCodeLoginOptions = { - ...data, - connection: 'PASSCODE' - } - - return await this.login(_data, 'passCode') - } - - async logout(): Promise { - await this.storage.remove(getWxLoginCodeKey(this.options.appId)) - - const loginState = await this.getLoginState() - - if (!loginState) { - return true - } - - const { access_token, expires_at } = loginState - - if (!access_token || expires_at < Date.now()) { - await this.clearLoginState() - return true - } - - await request({ - method: 'POST', - url: `${this.options.host}/oidc/token/revocation`, - data: { - client_id: this.options.appId, - token: access_token - }, - header: { - 'content-type': 'application/x-www-form-urlencoded' - } - }) - - await this.clearLoginState() - - return true - } - - async sendSms(data: SendSmsOptions): Promise { - data.phoneCountryCode = data.phoneCountryCode || '+86' - - return await request({ - method: 'POST', - url: `${this.options.host}/api/v3/send-sms`, - data, - header: { - 'x-authing-userpool-id': this.options.userPoolId - } - }) - } - - private async login( - data: + } + + const _data: PassCodeLoginOptions = { + ...data, + connection: 'PASSCODE' + } + + return await this.login(_data, 'passCode') + } + + async logout(): Promise> { + await this.storage.remove(getWxLoginCodeKey(this.options.appId)) + + const [loginStateError, loginState] = await this.getLoginState() + + if (loginStateError) { + return returnSuccess(true) + } + + const { access_token, expires_at } = loginState as LoginState + + if (!access_token || expires_at < Date.now()) { + await this.clearLoginState() + return returnSuccess(true) + } + + const [logoutError] = await request({ + method: 'POST', + url: `${this.options.host}/oidc/token/revocation`, + data: { + client_id: this.options.appId, + token: access_token + }, + header: { + 'content-type': 'application/x-www-form-urlencoded' + } + }) + + if (logoutError) { + return returnError(logoutError) + } + + await this.clearLoginState() + + return returnSuccess(true) + } + + async sendSms(data: SendSmsOptions): Promise> { + data.phoneCountryCode = data.phoneCountryCode || '+86' + + const [error, res] = await request({ + method: 'POST', + url: `${this.options.host}/api/v3/send-sms`, + data, + header: { + 'x-authing-userpool-id': this.options.userPoolId + } + }) + + if (error) { + return returnError(error) + } + + return returnSuccess(res) + } + + private async login( + data: | WxCodeLoginOptions | WxPhoneLoginOptions | PasswordLoginOptions | PassCodeLoginOptions, - type: string - ): Promise> { - const urlMap: Record = { - code: '/api/v3/signin-by-mobile', - phone: '/api/v3/signin-by-mobile', - password: '/api/v3/signin', - passCode: '/api/v3/signin' - } - - const res = await request({ - method: 'POST', - url: this.options.host + urlMap[type], - data, - header: { - 'x-authing-app-id': this.options.appId - } - }) - - if (res.access_token || res.id_token) { - const loginState = await this.saveLoginState(res) - return loginState - } - - await this.clearLoginState() - - error('login', res) - - return null - } - - async refreshToken(): Promise> { - const loginState = await this.getLoginState() - - if (!loginState) { - error('refreshToken', 'refresh_token has expired, please login again') - return null - } - - const { refresh_token, expires_at } = loginState - - if (!refresh_token) { - error('refreshToken', 'refresh_token must not be empty') - return null - } - - if (expires_at < Date.now()) { - error('refreshToken', 'refresh_token has expired, please login again') - return null - } - - const data: RefreshTokenOptions = { - grant_type: 'refresh_token', - redirect_uri: '', - refresh_token - } - - const res = await request({ - method: 'POST', - url: `${this.options.host}/oidc/token`, - data, - header: { - 'content-type': 'application/x-www-form-urlencoded', - 'x-authing-app-id': this.options.appId - } - }) - - if (res.access_token || res.id_token) { - const loginState = await this.saveLoginState(res) - return loginState - } - - error('refreshToken', res) - - return null - } - - async updatePassword( - data: UpdatePasswordOptions - ): Promise { - const loginState = await this.getLoginState() - - if (!loginState) { - error( - 'updatePassword', - 'Token has expired, please login again' - ) - return false - } - - const { access_token, expires_at } = loginState - - if (expires_at < Date.now()) { - error( - 'updatePassword', - 'Token has expired, please login again' - ) - return false - } - - if (data.passwordEncryptType && data.passwordEncryptType !== 'none') { - if (!this.encryptFunction) { - error( - 'updatePassword', - 'encryptFunction is required, if passwordEncryptType is not "none"' - ) - return false - } - - const publicKey = await this.getPublicKey(data.passwordEncryptType) - - if (!publicKey) { - error('loginByPassword', 'publicKey is invalid') - return false - } - - data.newPassword = this.encryptFunction(data.newPassword, publicKey) - } - - const res = await request({ - method: 'POST', - url: `${this.options.host}/api/v3/update-password`, - data, - header: { - 'x-authing-userpool-id': this.options.userPoolId, - Authorization: access_token - } - }) - - if (res.statusCode === 200) { - return true - } - - error('updatePassword', res) - - return false - } - - async getUserInfo(): Promise> { - const loginState = await this.getLoginState() - - if (!loginState) { - error('getUserInfo', 'Token has expired, please login again') - return null - } - - const { access_token, expires_at } = loginState - - if (expires_at < Date.now()) { - error('getUserInfo', 'Token has expired, please login again') - return null - } - - const res = await request({ - method: 'GET', - url: `${this.options.host}/api/v3/get-profile`, - header: { - 'x-authing-userpool-id': this.options.userPoolId, - Authorization: access_token - } - }) - - if (res.userId) { - return res - } - - error('getUserInfo', res) - - return null - } - - async updateAvatar(): Promise> { - try { - const res = await AuthingMove.chooseImage({ - count: 1, - sourceType: ['album', 'camera'], - sizeType: ['original'] - }) - - const uploadRes = await AuthingMove.uploadFile({ - url: `${this.options.host}/api/v2/upload?folder=avatar`, - name: 'file', - filePath: res.tempFiles[0].tempFilePath - }) - - const parsedUploadRed = JSON.parse(uploadRes.data) - - await this.updateUserInfo({ - photo: parsedUploadRed.data.url - }) - - return parsedUploadRed - } catch (e) { - error('updateAvatar', e) - return null - } - } - - async updateUserInfo(data: UpdateUserInfo): Promise> { - const loginState = await this.getLoginState() - - if (!loginState) { - error( - 'updateUserInfo', - 'Token has expired, please login again' - ) - return null - } - - const { access_token, expires_at } = loginState - - if (expires_at < Date.now()) { - error( - 'updateUserInfo', - 'Token has expired, please login again' - ) - } - - const res = await request({ - method: 'POST', - url: `${this.options.host}/api/v3/update-profile`, - data, - header: { - 'x-authing-userpool-id': this.options.userPoolId, - Authorization: access_token - } - }) - - if (res.userId) { - return res - } - - error('updateUserInfo', res) - - return null - } - - async getPhone(data: GetPhoneOptions): Promise { - const res = await request({ - method: 'POST', - url: `${this.options.host}/api/v3/get-wechat-miniprogram-phone`, - data, - header: { - 'x-authing-userpool-id': this.options.userPoolId - } - }) - - return res.phone_info || error('getPhone', res) - } + type: string + ): Promise> { + const urlMap: Record = { + code: '/api/v3/signin-by-mobile', + phone: '/api/v3/signin-by-mobile', + password: '/api/v3/signin', + passCode: '/api/v3/signin' + } + + const [error, res] = await request({ + method: 'POST', + url: this.options.host + urlMap[type], + data, + header: { + 'x-authing-app-id': this.options.appId + } + }) + + if (error) { + return returnError(error) + } + + if (res.access_token || res.id_token) { + const loginState = await this.saveLoginState(res) + return returnSuccess(loginState) + } + + await this.clearLoginState() + + return returnError(res) + } + + async refreshToken(): Promise> { + const [error, loginState] = await this.getLoginState() + + if (error) { + return returnError({ + message: 'refresh_token has expired, please login again' + }) + } + + const { refresh_token, expires_at } = loginState as LoginState + + if (!refresh_token) { + return returnError({ + message: 'refresh_token must not be empty' + }) + } + + if (expires_at < Date.now()) { + return returnError({ + message: 'refresh_token has expired, please login again' + }) + } + + const data: RefreshTokenOptions = { + grant_type: 'refresh_token', + redirect_uri: '', + refresh_token + } + + const [refreshTokenError, refreshTokenRes] = await request({ + method: 'POST', + url: `${this.options.host}/oidc/token`, + data, + header: { + 'content-type': 'application/x-www-form-urlencoded', + 'x-authing-app-id': this.options.appId + } + }) + + if (refreshTokenError) { + return returnError(refreshTokenError) + } + + const newLoginState = await this.saveLoginState(refreshTokenRes) + return returnSuccess(newLoginState) + } + + async updatePassword( + data: UpdatePasswordOptions + ): Promise> { + const [error, loginState] = await this.getLoginState() + + if (error) { + return returnError({ + message: 'Token has expired, please login again' + }) + } + + const { access_token, expires_at } = loginState as LoginState + + if (expires_at < Date.now()) { + return returnError({ + message: 'Token has expired, please login again' + }) + } + + if (data.passwordEncryptType && data.passwordEncryptType !== 'none') { + if (!this.encryptFunction) { + return returnError({ + message: 'encryptFunction is required, if passwordEncryptType is not "none"' + }) + } + + const [publicKeyError, publicKey] = await this.getPublicKey(data.passwordEncryptType) + + if (publicKeyError) { + return returnError(publicKeyError) + } + + data.newPassword = this.encryptFunction(data.newPassword, publicKey as string) + } + + const [updatePasswordError, updatePasswordRes] = await request({ + method: 'POST', + url: `${this.options.host}/api/v3/update-password`, + data, + header: { + 'x-authing-userpool-id': this.options.userPoolId, + Authorization: access_token + } + }) + + if (updatePasswordError) { + return returnError(updatePasswordError) + } + + return returnSuccess(updatePasswordRes) + } + + async getUserInfo(): Promise> { + const [error, loginState] = await this.getLoginState() + + if (error) { + return returnError({ + message: 'Token has expired, please login again' + }) + } + + const { access_token, expires_at } = loginState as LoginState + + if (expires_at < Date.now()) { + return returnError({ + message: 'Token has expired, please login again' + }) + } + + const [getProfileError, getProfileRes] = await request({ + method: 'GET', + url: `${this.options.host}/api/v3/get-profile`, + header: { + 'x-authing-userpool-id': this.options.userPoolId, + Authorization: access_token + } + }) + + if (getProfileError) { + return returnError(getProfileError) + } + + return returnSuccess(getProfileRes) + } + + async updateAvatar(): Promise> { + try { + const res = await AuthingMove.chooseImage({ + count: 1, + sourceType: ['album', 'camera'], + sizeType: ['original'] + }) + + const uploadRes = await AuthingMove.uploadFile({ + url: `${this.options.host}/api/v2/upload?folder=avatar`, + name: 'file', + filePath: res.tempFiles[0].tempFilePath + }) + + const parsedUploadRed = JSON.parse(uploadRes.data) + + await this.updateUserInfo({ + photo: parsedUploadRed.data.url + }) + + return returnSuccess(parsedUploadRed) + } catch (e) { + return returnError({ + message: JSON.stringify(e) + }) + } + } + + async updateUserInfo(data: UpdateUserInfo): Promise> { + const [error, loginState] = await this.getLoginState() + + if (error) { + return returnError({ + message: 'Token has expired, please login again' + }) + } + + const { access_token, expires_at } = loginState as LoginState + + if (expires_at < Date.now()) { + return returnError({ + message: 'Token has expired, please login again' + }) + } + + const [updateProfileError, updateProfileRes] = await request({ + method: 'POST', + url: `${this.options.host}/api/v3/update-profile`, + data, + header: { + 'x-authing-userpool-id': this.options.userPoolId, + Authorization: access_token + } + }) + + if (updateProfileError) { + return returnError(updateProfileError) + } + + return returnSuccess(updateProfileRes) + } + + async getPhone(data: GetPhoneOptions): Promise> { + const [getPhoneError, getPhoneRes] = await request({ + method: 'POST', + url: `${this.options.host}/api/v3/get-wechat-miniprogram-phone`, + data, + header: { + 'x-authing-userpool-id': this.options.userPoolId + } + }) + + if (getPhoneError) { + return returnError(getPhoneError) + } + + if (getPhoneRes.phone_info) { + return returnSuccess(getPhoneRes.phone_info) + } + + return returnError(getPhoneRes) + } } diff --git a/packages/miniapp/src/helpers/StorageProvider.ts b/packages/miniapp/src/helpers/StorageProvider.ts index d44864425..7287400ac 100644 --- a/packages/miniapp/src/helpers/StorageProvider.ts +++ b/packages/miniapp/src/helpers/StorageProvider.ts @@ -1,8 +1,8 @@ import { - GetStorageCallbackData, - RemoveStorageFailData, - RemoveStorageSuccessData, - SetStorageCallbackData + GetStorageCallbackData, + RemoveStorageFailData, + RemoveStorageSuccessData, + SetStorageCallbackData } from '@authing/authingmove-core' import { AuthingMove } from '../AuthingMove' @@ -10,24 +10,24 @@ import { AuthingMove } from '../AuthingMove' import { IStorageProvider } from '../types' export class StorageProvider implements IStorageProvider { - get(key: string): Promise { - return AuthingMove.getStorage({ - key - }) - } + get(key: string): Promise { + return AuthingMove.getStorage({ + key + }) + } - set(key: string, data: unknown): Promise { - return AuthingMove.setStorage({ - key, - data - }) - } + set(key: string, data: unknown): Promise { + return AuthingMove.setStorage({ + key, + data + }) + } - remove( - key: string - ): Promise { - return AuthingMove.removeStorage({ - key - }) - } + remove( + key: string + ): Promise { + return AuthingMove.removeStorage({ + key + }) + } } diff --git a/packages/miniapp/src/helpers/index.ts b/packages/miniapp/src/helpers/index.ts index fff5099c7..cfbb99120 100644 --- a/packages/miniapp/src/helpers/index.ts +++ b/packages/miniapp/src/helpers/index.ts @@ -1,4 +1,4 @@ export * from './StorageProvider' export * from './request' export * from './utils' -export * from './log' +export * from './return' diff --git a/packages/miniapp/src/helpers/log.ts b/packages/miniapp/src/helpers/log.ts deleted file mode 100644 index b45a55ea5..000000000 --- a/packages/miniapp/src/helpers/log.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function error(errorFunction: string, errorContent: any) { - console.error( - `Authing Miniapp SDK error in "${errorFunction}": `, - errorContent - ) -} diff --git a/packages/miniapp/src/helpers/request.ts b/packages/miniapp/src/helpers/request.ts index 70565b28e..317e84b54 100644 --- a/packages/miniapp/src/helpers/request.ts +++ b/packages/miniapp/src/helpers/request.ts @@ -4,22 +4,88 @@ import { WxRequestConfig } from '@authing/authingmove-core' import { version } from '../../package.json' -export async function request(options: WxRequestConfig) { - try { - const _options = Object.assign({}, options, { - header: { - ...options.header, - 'x-authing-request-from': 'sdk-miniapp', - 'x-authing-sdk-version': version - } - }) - - const { data } = await AuthingMove.request(_options) - - // /oidc/token 直接返回 data,没有 data.data - // /api/v3/update-password 只返回 message 和 statusCode,没有 data.data - return data.data || data - } catch (e) { - return e - } +import { returnSuccess, returnError } from './return' + +import { SDKResponse } from 'src/types' + +enum ResponseDataType { + NOT_INTERCEPTED_SUCCESS = 0, + NOT_INTERCEPTED_ERROR = 1, + INTERCEPTED_SUCCESS = 2, + INTERCEPTED_ERROR = 3 +} + +export async function request(options: WxRequestConfig): Promise> { + try { + const _options = Object.assign({}, options, { + header: { + ...options.header, + 'x-authing-request-from': 'sdk-miniapp', + 'x-authing-sdk-version': version + } + }) + + const { data } = await AuthingMove.request(_options) + + const interceptRes = interceptResponseData(options.url, data) + + switch (interceptRes) { + case ResponseDataType.NOT_INTERCEPTED_SUCCESS: + return returnSuccess(data.data || data) + case ResponseDataType.INTERCEPTED_SUCCESS: + return returnSuccess(data) + default: + return returnError(data) + } + } catch (e) { + return returnError({ + message: JSON.stringify(e) + }) + } +} + +function interceptResponseData (url: string, data: any) { + const intercepts = [ + { + test: /(\/api\/v3\/system)$/, + validate () { + if (data.rsa || data.sm2) { + return true + } + return false + } + }, + { + test: /(\/oidc\/token)$/, + validate () { + if (data.access_token || data.id_token) { + return true + } + return false + } + }, + { + test: /(\/oidc\/token\/revocation)$/, + validate () { + return data === '' + } + } + ] + + for (let i = 0; i < intercepts.length; i++) { + const matched = url.match(intercepts[i].test) + + if (matched && matched[0]) { + if (intercepts[i].validate()) { + return ResponseDataType.INTERCEPTED_SUCCESS + } + return ResponseDataType.INTERCEPTED_ERROR + } + } + + if (data.statusCode === 200) { + return ResponseDataType.NOT_INTERCEPTED_SUCCESS + } + + return ResponseDataType.NOT_INTERCEPTED_ERROR } diff --git a/packages/miniapp/src/helpers/return.ts b/packages/miniapp/src/helpers/return.ts new file mode 100644 index 000000000..27fb891df --- /dev/null +++ b/packages/miniapp/src/helpers/return.ts @@ -0,0 +1,9 @@ +import { ErrorData, SDKResponseSuccess, SDKResponseError } from '../types' + +export function returnSuccess (data: T): SDKResponseSuccess { + return [null, data] +} + +export function returnError (errorData: ErrorData): SDKResponseError { + return [errorData, undefined] +} diff --git a/packages/miniapp/src/helpers/utils.ts b/packages/miniapp/src/helpers/utils.ts index 6e337c302..cbccfc41d 100644 --- a/packages/miniapp/src/helpers/utils.ts +++ b/packages/miniapp/src/helpers/utils.ts @@ -1,7 +1,7 @@ export function getLoginStateKey(appId: string): string { - return ['authing', appId, 'login-state'].join(':') + return ['authing', appId, 'login-state'].join(':') } export function getWxLoginCodeKey (appId: string): string { - return ['authing', appId, 'wx-login-code'].join(':') + return ['authing', appId, 'wx-login-code'].join(':') } diff --git a/packages/miniapp/src/types.ts b/packages/miniapp/src/types.ts index b2f11fc84..1fa8fc79a 100644 --- a/packages/miniapp/src/types.ts +++ b/packages/miniapp/src/types.ts @@ -1,9 +1,9 @@ import { - GetStorageCallbackData, - IObject, - RemoveStorageFailData, - RemoveStorageSuccessData, - SetStorageCallbackData + GetStorageCallbackData, + IObject, + RemoveStorageFailData, + RemoveStorageSuccessData, + SetStorageCallbackData } from '@authing/authingmove-core' export interface AuthingOptions { @@ -18,11 +18,11 @@ export interface EncryptFunction { } export declare abstract class IStorageProvider { - get(key: string): Promise + get(key: string): Promise - set(key: string, data: unknown): Promise + set(key: string, data: unknown): Promise - remove(key: string): Promise + remove(key: string): Promise } export interface LoginOptions { @@ -344,4 +344,14 @@ export interface UploadFileResponseData { } } -export type Maybe = T | null +export type SDKResponse = SDKResponseSuccess | SDKResponseError + +export type SDKResponseSuccess = [null, T] + +export type SDKResponseError = [ErrorData, undefined] + +export interface ErrorData { + message: unknown + statusCode?: number + apiCode?: number +} diff --git a/packages/web/src/Authing.ts b/packages/web/src/Authing.ts index af355a931..11a800201 100644 --- a/packages/web/src/Authing.ts +++ b/packages/web/src/Authing.ts @@ -1,26 +1,26 @@ import { axiosGet, axiosPost } from './axios' import { - DEFAULT_IFRAME_LOGINSTATE_TIMEOUT, - DEFAULT_POPUP_HEIGHT, - DEFAULT_POPUP_WIDTH, - DEFAULT_SCOPE, - MSG_CROSS_ORIGIN_ISOLATED, - MSG_PENDING_AUTHZ + DEFAULT_IFRAME_LOGINSTATE_TIMEOUT, + DEFAULT_POPUP_HEIGHT, + DEFAULT_POPUP_WIDTH, + DEFAULT_SCOPE, + MSG_CROSS_ORIGIN_ISOLATED, + MSG_PENDING_AUTHZ } from './constants' import { - AuthingSPAInitOptions, - LoginState, - IDToken, - AccessToken, - LoginTransaction, - AuthzURLParams, - OIDCWebMessageResponse, - PKCETokenParams, - OIDCTokenResponse, - LoginStateWithCustomStateData, - LogoutURLParams, - IUserInfo, - NormalError + AuthingSPAInitOptions, + LoginState, + IDToken, + AccessToken, + LoginTransaction, + AuthzURLParams, + OIDCWebMessageResponse, + PKCETokenParams, + OIDCTokenResponse, + LoginStateWithCustomStateData, + LogoutURLParams, + IUserInfo, + NormalError } from './global' import { InMemoryStorageProvider } from './storage/InMemoryStorgeProvider' import { StorageProvider } from './storage/interface' @@ -29,63 +29,63 @@ import { NullStorageProvider } from './storage/NullStorageProvider' import { SessionStorageProvider } from './storage/SessionStorageProvider' import { MsgListener, StrDict } from './types' import { - createQueryParams, - createRandomString, - domainC14n, - genPKCEPair, - getCrypto, - getCryptoSubtle, - isIE, - loginStateKey, - parseToken, - transactionKey + createQueryParams, + createRandomString, + domainC14n, + genPKCEPair, + getCrypto, + getCryptoSubtle, + isIE, + loginStateKey, + parseToken, + transactionKey } from './utils' export class Authing { - private globalMsgListener: MsgListener | null | undefined - - private readonly options: Required - private readonly loginStateProvider: StorageProvider - private readonly transactionProvider: StorageProvider - private readonly domain: string - - constructor(options: AuthingSPAInitOptions) { - this.options = options as any - this.domain = domainC14n(this.options.domain) - - if (!options.useImplicitMode && (!getCrypto() || !getCryptoSubtle())) { - throw new Error( - 'PKCE 模式需要浏览器 crypto 能力, 请确保浏览器处于 https 域名下,或设置 useImplicitMode 为 true' - ) - } - - if (typeof localStorage === 'object') { - this.loginStateProvider = new LocalStorageProvider() - } else { - console.warn('您的浏览器版本过低,登录态存储功能将不可用') - this.loginStateProvider = new InMemoryStorageProvider() - } - - if (typeof sessionStorage === 'object') { - this.transactionProvider = new SessionStorageProvider() - } else { - if (!options.useImplicitMode) { - console.warn( - '您的浏览器版本过低,PKCE 重定向认证功能将不可用,请设置 useImplicitMode 为 true' - ) - } - this.transactionProvider = new NullStorageProvider() - } - - options.implicitResponseType = + private globalMsgListener: MsgListener | null | undefined + + private readonly options: Required + private readonly loginStateProvider: StorageProvider + private readonly transactionProvider: StorageProvider + private readonly domain: string + + constructor(options: AuthingSPAInitOptions) { + this.options = options as any + this.domain = domainC14n(this.options.domain) + + if (!options.useImplicitMode && (!getCrypto() || !getCryptoSubtle())) { + throw new Error( + 'PKCE 模式需要浏览器 crypto 能力, 请确保浏览器处于 https 域名下,或设置 useImplicitMode 为 true' + ) + } + + if (typeof localStorage === 'object') { + this.loginStateProvider = new LocalStorageProvider() + } else { + console.warn('您的浏览器版本过低,登录态存储功能将不可用') + this.loginStateProvider = new InMemoryStorageProvider() + } + + if (typeof sessionStorage === 'object') { + this.transactionProvider = new SessionStorageProvider() + } else { + if (!options.useImplicitMode) { + console.warn( + '您的浏览器版本过低,PKCE 重定向认证功能将不可用,请设置 useImplicitMode 为 true' + ) + } + this.transactionProvider = new NullStorageProvider() + } + + options.implicitResponseType = options.implicitResponseType ?? 'id_token token' - options.redirectResponseMode = options.redirectResponseMode ?? 'fragment' - options.popupWidth = options.popupWidth ?? DEFAULT_POPUP_WIDTH - options.popupHeight = options.popupHeight ?? DEFAULT_POPUP_HEIGHT - options.scope = options.scope ?? DEFAULT_SCOPE - } + options.redirectResponseMode = options.redirectResponseMode ?? 'fragment' + options.popupWidth = options.popupWidth ?? DEFAULT_POPUP_WIDTH + options.popupHeight = options.popupHeight ?? DEFAULT_POPUP_HEIGHT + options.scope = options.scope ?? DEFAULT_SCOPE + } - /** + /** * 按顺序用以下方式获取用户登录态: * * 1. 本地缓存获取 @@ -93,130 +93,130 @@ export class Authing { * * @param options.ignoreCache 忽略本地缓存 */ - async getLoginState( - options: { + async getLoginState( + options: { ignoreCache?: boolean } = {} - ): Promise { - // 1. 从 loginStateProvider 中(默认为 localStorage)获取 - if (!options.ignoreCache) { - const state = await this.loginStateProvider.get( - loginStateKey(this.options.appId) - ) - if (state && state.expireAt && state.expireAt > Date.now()) { - if (!this.options.introspectAccessToken || !state.accessToken) { - return state - } - - const { data } = await axiosPost( - `${this.domain}/oidc/token/introspection`, - createQueryParams({ - client_id: this.options.appId, - token: state.accessToken - }), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - } - ) - - if (data.active === true) { - return state - } - } - } - - // 清掉旧的登录态 - await this.loginStateProvider.delete(loginStateKey(this.options.appId)) - - // 2. 用隐藏 iframe 获取 - if (this.globalMsgListener !== undefined) { - throw new Error(MSG_PENDING_AUTHZ) - } - this.globalMsgListener = null - - if (window.crossOriginIsolated) { - // 如果是 crossOriginIsolated 就发不了 postMessage 了 - console.warn('当前页面运行在隔离模式下,无法获取登录态') - return null - } - - const state = createRandomString(16) - const nonce = createRandomString(16) - let codeVerifier: string | undefined - const redirectUrl = this.options.redirectUri ?? window.location.origin - - const params: AuthzURLParams = { - redirect_uri: redirectUrl, - response_mode: 'web_message', - response_type: this.options.useImplicitMode - ? this.options.implicitResponseType - : 'code', - client_id: this.options.appId, - state, - nonce, - prompt: 'none', - scope: this.options.scope - } - - if (!this.options.useImplicitMode) { - const { codeChallenge, codeVerifier: v } = await genPKCEPair() - codeVerifier = v - params.code_challenge = codeChallenge - params.code_challenge_method = 'S256' - } - - const iframe = document.createElement('iframe') - // iframe.title = 'postMessage() Initiator'; - iframe.hidden = true - iframe.width = iframe.height = '0' - - iframe.src = `${this.domain}/oidc/auth?${createQueryParams(params)}` - if (isIE()) { - document.body.appendChild(iframe) - } else { - document.body.append(iframe) - } - - const res = await Promise.race([ - this.listenToPostMessage(state), - new Promise(resolve => - setTimeout(() => resolve(null), DEFAULT_IFRAME_LOGINSTATE_TIMEOUT) - ) - ]) - - if (this.globalMsgListener) { - window.removeEventListener('message', this.globalMsgListener) - } - this.globalMsgListener = undefined - - iframe.remove() - - if (res === null) { - console.warn('登录态获取超时') - return null - } - - if (res.error) { - if (res.error !== 'login_required') { - console.warn( - `登录态获取失败,认证服务器返回错误: error=${res.error}, error_description=${res.errorDesc}` - ) - } else { - console.info('用户未登录') - } - return null - } - - if (res.state !== state) { - throw new Error('state 验证失败') - } - - return this.handleOIDCWebMsgResponse(res, nonce, redirectUrl, codeVerifier) - } - - /** + ): Promise { + // 1. 从 loginStateProvider 中(默认为 localStorage)获取 + if (!options.ignoreCache) { + const state = await this.loginStateProvider.get( + loginStateKey(this.options.appId) + ) + if (state && state.expireAt && state.expireAt > Date.now()) { + if (!this.options.introspectAccessToken || !state.accessToken) { + return state + } + + const { data } = await axiosPost( + `${this.domain}/oidc/token/introspection`, + createQueryParams({ + client_id: this.options.appId, + token: state.accessToken + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ) + + if (data.active === true) { + return state + } + } + } + + // 清掉旧的登录态 + await this.loginStateProvider.delete(loginStateKey(this.options.appId)) + + // 2. 用隐藏 iframe 获取 + if (this.globalMsgListener !== undefined) { + throw new Error(MSG_PENDING_AUTHZ) + } + this.globalMsgListener = null + + if (window.crossOriginIsolated) { + // 如果是 crossOriginIsolated 就发不了 postMessage 了 + console.warn('当前页面运行在隔离模式下,无法获取登录态') + return null + } + + const state = createRandomString(16) + const nonce = createRandomString(16) + let codeVerifier: string | undefined + const redirectUrl = this.options.redirectUri ?? window.location.origin + + const params: AuthzURLParams = { + redirect_uri: redirectUrl, + response_mode: 'web_message', + response_type: this.options.useImplicitMode + ? this.options.implicitResponseType + : 'code', + client_id: this.options.appId, + state, + nonce, + prompt: 'none', + scope: this.options.scope + } + + if (!this.options.useImplicitMode) { + const { codeChallenge, codeVerifier: v } = await genPKCEPair() + codeVerifier = v + params.code_challenge = codeChallenge + params.code_challenge_method = 'S256' + } + + const iframe = document.createElement('iframe') + // iframe.title = 'postMessage() Initiator'; + iframe.hidden = true + iframe.width = iframe.height = '0' + + iframe.src = `${this.domain}/oidc/auth?${createQueryParams(params)}` + if (isIE()) { + document.body.appendChild(iframe) + } else { + document.body.append(iframe) + } + + const res = await Promise.race([ + this.listenToPostMessage(state), + new Promise(resolve => + setTimeout(() => resolve(null), DEFAULT_IFRAME_LOGINSTATE_TIMEOUT) + ) + ]) + + if (this.globalMsgListener) { + window.removeEventListener('message', this.globalMsgListener) + } + this.globalMsgListener = undefined + + iframe.remove() + + if (res === null) { + console.warn('登录态获取超时') + return null + } + + if (res.error) { + if (res.error !== 'login_required') { + console.warn( + `登录态获取失败,认证服务器返回错误: error=${res.error}, error_description=${res.errorDesc}` + ) + } else { + console.info('用户未登录') + } + return null + } + + if (res.state !== state) { + throw new Error('state 验证失败') + } + + return this.handleOIDCWebMsgResponse(res, nonce, redirectUrl, codeVerifier) + } + + /** * 将用户重定向到 Authing 认证端点进行登录,需要配合 handleRedirectCallback 使用 * * @param options.redirectUri 回调地址,默认为初始化参数中的 redirectUri @@ -224,272 +224,272 @@ export class Authing { * @param options.forced 即使在用户已登录时也提示用户再次登录 * @param options.customState 自定义的中间状态,会被传递到回调端点 */ - async loginWithRedirect( - options: { + async loginWithRedirect( + options: { redirectUri?: string originalUri?: string forced?: boolean customState?: any } = {} - ): Promise { - const redirectUri = options.redirectUri || this.options.redirectUri - if (!redirectUri) { - throw new Error('必须设置 redirect_uri') - } - - const state = createRandomString(16) - const nonce = createRandomString(16) - - const params: AuthzURLParams = { - redirect_uri: redirectUri, - response_mode: this.options.redirectResponseMode, - response_type: this.options.useImplicitMode - ? this.options.implicitResponseType - : 'code', - client_id: this.options.appId, - ...(options.forced && { prompt: 'login' }), - state, - nonce, - scope: this.options.scope - } - - let codeVerifier: string | undefined - if (!this.options.useImplicitMode) { - const { codeChallenge, codeVerifier: v } = await genPKCEPair() - params.code_challenge = codeChallenge - params.code_challenge_method = 'S256' - codeVerifier = v - } - - await this.transactionProvider.put( - transactionKey(this.options.appId, state), - { - codeVerifier, - state, - redirectUri, - nonce, - ...(this.options.redirectToOriginalUri && { - originalUri: options.originalUri ?? window.location.href - }), - ...(options.customState !== undefined && { - customState: options.customState - }) - } - ) - - window.location.replace( - `${this.domain}/oidc/auth?${createQueryParams(params)}` - ) - } - - /** + ): Promise { + const redirectUri = options.redirectUri || this.options.redirectUri + if (!redirectUri) { + throw new Error('必须设置 redirect_uri') + } + + const state = createRandomString(16) + const nonce = createRandomString(16) + + const params: AuthzURLParams = { + redirect_uri: redirectUri, + response_mode: this.options.redirectResponseMode, + response_type: this.options.useImplicitMode + ? this.options.implicitResponseType + : 'code', + client_id: this.options.appId, + ...(options.forced && { prompt: 'login' }), + state, + nonce, + scope: this.options.scope + } + + let codeVerifier: string | undefined + if (!this.options.useImplicitMode) { + const { codeChallenge, codeVerifier: v } = await genPKCEPair() + params.code_challenge = codeChallenge + params.code_challenge_method = 'S256' + codeVerifier = v + } + + await this.transactionProvider.put( + transactionKey(this.options.appId, state), + { + codeVerifier, + state, + redirectUri, + nonce, + ...(this.options.redirectToOriginalUri && { + originalUri: options.originalUri ?? window.location.href + }), + ...(options.customState !== undefined && { + customState: options.customState + }) + } + ) + + window.location.replace( + `${this.domain}/oidc/auth?${createQueryParams(params)}` + ) + } + + /** * 判断当前 URL 是否为 Authing 登录回调 URL */ - isRedirectCallback(): boolean { - const params = this.resolveCallbackParams() + isRedirectCallback(): boolean { + const params = this.resolveCallbackParams() - if (!params) { - return false - } + if (!params) { + return false + } - if (params['error']) { - return true - } + if (params['error']) { + return true + } - if (this.options.useImplicitMode) { - return !!(params['access_token'] || params['id_token']) - } else { - return !!params['code'] - } - } + if (this.options.useImplicitMode) { + return !!(params['access_token'] || params['id_token']) + } else { + return !!params['code'] + } + } - /** + /** * 在回调端点处理 Authing 发送的授权码或 token,获取用户登录态 */ - async handleRedirectCallback(): Promise { - const paramDict = this.resolveCallbackParams() - if (!paramDict) { - throw new Error('非法的回调 URL') - } - - if (paramDict.error) { - throw new Error( - `认证失败, error=${paramDict.error}, error_description=${paramDict.error_description}` - ) - } - - let originalUri: string | undefined - let customState: any - - const { state } = paramDict - if (!state) { - throw new Error('非法的回调 URL: 缺少 state') - } - const tx = await this.transactionProvider.get( - transactionKey(this.options.appId, state) - ) - if (tx) { - await this.transactionProvider.delete( - transactionKey(this.options.appId, state) - ) - - if (tx.state !== state) { - throw new Error('state 验证失败') - } - - originalUri = tx.originalUri - customState = tx.customState - if (!this.options.useImplicitMode) { - // PKCE code flow - const { code } = paramDict - if (!code) { - throw new Error('非法的回调 URL: 缺少 code') - } - const res = await this.exchangeToken( - code, - tx.redirectUri, + async handleRedirectCallback(): Promise { + const paramDict = this.resolveCallbackParams() + if (!paramDict) { + throw new Error('非法的回调 URL') + } + + if (paramDict.error) { + throw new Error( + `认证失败, error=${paramDict.error}, error_description=${paramDict.error_description}` + ) + } + + let originalUri: string | undefined + let customState: any + + const { state } = paramDict + if (!state) { + throw new Error('非法的回调 URL: 缺少 state') + } + const tx = await this.transactionProvider.get( + transactionKey(this.options.appId, state) + ) + if (tx) { + await this.transactionProvider.delete( + transactionKey(this.options.appId, state) + ) + + if (tx.state !== state) { + throw new Error('state 验证失败') + } + + originalUri = tx.originalUri + customState = tx.customState + if (!this.options.useImplicitMode) { + // PKCE code flow + const { code } = paramDict + if (!code) { + throw new Error('非法的回调 URL: 缺少 code') + } + const res = await this.exchangeToken( + code, + tx.redirectUri, tx.codeVerifier as string, tx.nonce - ) - - if (this.options.redirectToOriginalUri && originalUri) { - window.location.replace(originalUri) - } - - return res - } - } else if (!this.options.useImplicitMode) { - throw new Error( - '获取登录流程会话失败, 请确认是否重复访问了回调端点,以及浏览器是否支持 sessionStorage' - ) - } - - // implicit flow - const idToken = paramDict.id_token - const accessToken = paramDict.access_token - const nonce = tx?.nonce - - if ( - (this.options.implicitResponseType.includes('token') && !accessToken) || + ) + + if (this.options.redirectToOriginalUri && originalUri) { + window.location.replace(originalUri) + } + + return res + } + } else if (!this.options.useImplicitMode) { + throw new Error( + '获取登录流程会话失败, 请确认是否重复访问了回调端点,以及浏览器是否支持 sessionStorage' + ) + } + + // implicit flow + const idToken = paramDict.id_token + const accessToken = paramDict.access_token + const nonce = tx?.nonce + + if ( + (this.options.implicitResponseType.includes('token') && !accessToken) || (this.options.implicitResponseType.includes('id_token') && !idToken) - ) { - throw new Error('非法的回调 URL: 缺少 token') - } + ) { + throw new Error('非法的回调 URL: 缺少 token') + } - const result = await this.saveLoginState({ - idToken, - accessToken, - nonce - }) + const result = await this.saveLoginState({ + idToken, + accessToken, + nonce + }) - if (this.options.redirectToOriginalUri && originalUri) { - window.location.replace(originalUri) - } + if (this.options.redirectToOriginalUri && originalUri) { + window.location.replace(originalUri) + } - return { ...result, customState } - } + return { ...result, customState } + } - /** + /** * 弹出一个新的 Authing 登录页面窗口,在其中完成登录 * * @param options.redirectUri 回调地址,需要和当前页面在 same origin 下;默认为初始化参数中的 redirectUri 或 window.location.origin * @param options.forced 即使在用户已登录时也提示用户再次登录 */ - async loginWithPopup( - options: { redirectUri?: string; forced?: boolean } = {} - ): Promise { - const redirectUri = + async loginWithPopup( + options: { redirectUri?: string; forced?: boolean } = {} + ): Promise { + const redirectUri = options.redirectUri || this.options.redirectUri || window.location.origin - if (this.globalMsgListener !== undefined) { - throw new Error(MSG_PENDING_AUTHZ) - } - this.globalMsgListener = null - - if (window.crossOriginIsolated) { - // 如果是 crossOriginIsolated 就发不了 postMessage 了 - throw new Error(MSG_CROSS_ORIGIN_ISOLATED) - } - - const state = createRandomString(16) - const nonce = createRandomString(16) - - const params: AuthzURLParams = { - redirect_uri: redirectUri, - response_mode: 'web_message', - response_type: this.options.useImplicitMode - ? this.options.implicitResponseType - : 'code', - client_id: this.options.appId, - state, - nonce, - ...(options.forced && { prompt: 'login' }), - scope: this.options.scope - } - - let codeVerifier: string | undefined - if (!this.options.useImplicitMode) { - const { codeChallenge, codeVerifier: v } = await genPKCEPair() - codeVerifier = v - params.code_challenge = codeChallenge - params.code_challenge_method = 'S256' - } - - const url = `${this.domain}/oidc/auth?${createQueryParams(params)}` - const win = window.open( - url, - 'authing-spa-login-window', - `popup,width=${this.options.popupWidth},height=${this.options.popupHeight}` - ) - if (!win) { - throw new Error('弹出窗口失败') - } - - const res = await Promise.race([ - this.listenToPostMessage(state), - new Promise(resolve => { - const handle = setInterval(() => { - if (win.closed) { - clearInterval(handle) - // 防止 post message 事件和 close 事件同时到达 - setTimeout(() => resolve(null), 500) - } - }, 500) - }) - ]) - if (this.globalMsgListener) { - window.removeEventListener('message', this.globalMsgListener) - } - this.globalMsgListener = undefined - - if (!res) { - // 窗口被用户关闭了 - return null - } - - if (res.error) { - throw new Error( - `登录失败,认证服务器返回错误: error=${res.error}, error_description=${res.errorDesc}` - ) - } - - if (res.state !== state) { - throw new Error('state 验证失败') - } - - return this.handleOIDCWebMsgResponse(res, nonce, redirectUri, codeVerifier) - } - - // /** - // * 由于 iframe 存在跨域 cookie 无法携带以及联邦认证支持问题,暂时不支持本方法 - // * - // * 在指定的 iframe 中显示 Authing 登录页面,在其中完成登录 - // * - // * 注意: 当需要手动关闭 iframe 时,必须同时调用 abortIframeLogin 方法 - // * - // * @param options.forced 即使在用户已登录时也提示用户再次登录 - // */ - /* + if (this.globalMsgListener !== undefined) { + throw new Error(MSG_PENDING_AUTHZ) + } + this.globalMsgListener = null + + if (window.crossOriginIsolated) { + // 如果是 crossOriginIsolated 就发不了 postMessage 了 + throw new Error(MSG_CROSS_ORIGIN_ISOLATED) + } + + const state = createRandomString(16) + const nonce = createRandomString(16) + + const params: AuthzURLParams = { + redirect_uri: redirectUri, + response_mode: 'web_message', + response_type: this.options.useImplicitMode + ? this.options.implicitResponseType + : 'code', + client_id: this.options.appId, + state, + nonce, + ...(options.forced && { prompt: 'login' }), + scope: this.options.scope + } + + let codeVerifier: string | undefined + if (!this.options.useImplicitMode) { + const { codeChallenge, codeVerifier: v } = await genPKCEPair() + codeVerifier = v + params.code_challenge = codeChallenge + params.code_challenge_method = 'S256' + } + + const url = `${this.domain}/oidc/auth?${createQueryParams(params)}` + const win = window.open( + url, + 'authing-spa-login-window', + `popup,width=${this.options.popupWidth},height=${this.options.popupHeight}` + ) + if (!win) { + throw new Error('弹出窗口失败') + } + + const res = await Promise.race([ + this.listenToPostMessage(state), + new Promise(resolve => { + const handle = setInterval(() => { + if (win.closed) { + clearInterval(handle) + // 防止 post message 事件和 close 事件同时到达 + setTimeout(() => resolve(null), 500) + } + }, 500) + }) + ]) + if (this.globalMsgListener) { + window.removeEventListener('message', this.globalMsgListener) + } + this.globalMsgListener = undefined + + if (!res) { + // 窗口被用户关闭了 + return null + } + + if (res.error) { + throw new Error( + `登录失败,认证服务器返回错误: error=${res.error}, error_description=${res.errorDesc}` + ) + } + + if (res.state !== state) { + throw new Error('state 验证失败') + } + + return this.handleOIDCWebMsgResponse(res, nonce, redirectUri, codeVerifier) + } + + // /** + // * 由于 iframe 存在跨域 cookie 无法携带以及联邦认证支持问题,暂时不支持本方法 + // * + // * 在指定的 iframe 中显示 Authing 登录页面,在其中完成登录 + // * + // * 注意: 当需要手动关闭 iframe 时,必须同时调用 abortIframeLogin 方法 + // * + // * @param options.forced 即使在用户已登录时也提示用户再次登录 + // */ + /* async loginWithIframe( iframe: HTMLIFrameElement, options: { forced?: boolean } = {}, @@ -549,10 +549,10 @@ export class Authing { } */ - /** + /** * 手动中止 iframe 登录, 并移除 SDK 注册的事件监听器 */ - /* + /* abortIframeLogin(): void { if (this.globalMsgListener) { window.removeEventListener('message', this.globalMsgListener); @@ -561,240 +561,240 @@ export class Authing { } */ - /** + /** * 用 Access Token 获取用户身份信息 * * @param options.accessToken Access Token,默认从登录态中获取 */ - async getUserInfo( - options: { + async getUserInfo( + options: { accessToken?: string } = {} - ): Promise { - const accessToken = + ): Promise { + const accessToken = options.accessToken ?? (await this.getLoginState())?.accessToken - if (!accessToken) { - throw new Error('未传入 access token') - } - - const { data } = await axiosGet(`${this.domain}/api/v3/get-profile`, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'x-authing-userpool-id': this.options.userPoolId - } - }) - - if (data.data) { - return data.data as IUserInfo - } - - return { - apiCode: data.apiCode, - message: data.message, - statusCode: data.statusCode - } - } - - /** + if (!accessToken) { + throw new Error('未传入 access token') + } + + const { data } = await axiosGet(`${this.domain}/api/v3/get-profile`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'x-authing-userpool-id': this.options.userPoolId + } + }) + + if (data.data) { + return data.data as IUserInfo + } + + return { + apiCode: data.apiCode, + message: data.message, + statusCode: data.statusCode + } + } + + /** * 重定向到 Authing 的登出端点,完成登出操作 * * @param options.redirectUri 登出完成后的回调地址,默认为初始化参数中的 logoutRedirectUri * @param options.state 自定义中间状态 */ - async logoutWithRedirect( - options: { + async logoutWithRedirect( + options: { redirectUri?: string | null state?: string } = {} - ): Promise { - const loginState = await this.loginStateProvider.get( - loginStateKey(this.options.appId) - ) - if (!loginState) { - return - } - await this.loginStateProvider.delete(loginStateKey(this.options.appId)) - - const params: LogoutURLParams = { - id_token_hint: loginState.idToken - } - - const logoutRedirectUri = + ): Promise { + const loginState = await this.loginStateProvider.get( + loginStateKey(this.options.appId) + ) + if (!loginState) { + return + } + await this.loginStateProvider.delete(loginStateKey(this.options.appId)) + + const params: LogoutURLParams = { + id_token_hint: loginState.idToken + } + + const logoutRedirectUri = options.redirectUri ?? this.options.logoutRedirectUri - if (logoutRedirectUri) { - params.post_logout_redirect_uri = logoutRedirectUri - params.state = options.state - } - - await this.loginStateProvider.delete(loginStateKey(this.options.appId)) - - window.location.replace( - `${this.domain}/oidc/session/end?${createQueryParams(params)}` - ) - return - } - - private async listenToPostMessage(state: string) { - return new Promise((resolve, reject) => { - const msgEventListener = (msgEvent: MessageEvent) => { - if ( - msgEvent.origin !== this.domain || + if (logoutRedirectUri) { + params.post_logout_redirect_uri = logoutRedirectUri + params.state = options.state + } + + await this.loginStateProvider.delete(loginStateKey(this.options.appId)) + + window.location.replace( + `${this.domain}/oidc/session/end?${createQueryParams(params)}` + ) + return + } + + private async listenToPostMessage(state: string) { + return new Promise((resolve, reject) => { + const msgEventListener = (msgEvent: MessageEvent) => { + if ( + msgEvent.origin !== this.domain || msgEvent.data?.type !== 'authorization_response' - ) { - return - } - - window.removeEventListener('message', msgEventListener) - this.globalMsgListener = undefined - - const { response } = msgEvent.data - if (!response || response.state !== state) { - return reject(new Error('非法的服务端返回值')) - } - - if (response.error) { - return resolve({ - error: response.error, - errorDesc: response.error_description - }) - } - - return resolve({ - accessToken: response.access_token, - idToken: response.id_token, - refreshToken: response.refresh_token, - code: response.code, - state: response.state - }) - } - - this.globalMsgListener = msgEventListener - window.addEventListener('message', msgEventListener) - }) - } - - private async saveLoginState(params: { + ) { + return + } + + window.removeEventListener('message', msgEventListener) + this.globalMsgListener = undefined + + const { response } = msgEvent.data + if (!response || response.state !== state) { + return reject(new Error('非法的服务端返回值')) + } + + if (response.error) { + return resolve({ + error: response.error, + errorDesc: response.error_description + }) + } + + return resolve({ + accessToken: response.access_token, + idToken: response.id_token, + refreshToken: response.refresh_token, + code: response.code, + state: response.state + }) + } + + this.globalMsgListener = msgEventListener + window.addEventListener('message', msgEventListener) + }) + } + + private async saveLoginState(params: { accessToken?: string idToken?: string nonce?: string }) { - const { accessToken, idToken } = params - const loginState: LoginState = { - accessToken: accessToken, - idToken: idToken, - timestamp: Date.now() - } - - if (idToken) { - const parsedIdToken: IDToken = parseToken(idToken).body - loginState.parsedIdToken = parsedIdToken - loginState.expireAt = parsedIdToken.exp * 1000 - - if (params.nonce && parsedIdToken.nonce !== params.nonce) { - throw new Error('nonce 验证失败') - } - } - - if (accessToken) { - const parsedAccessToken: AccessToken = parseToken(accessToken).body - loginState.parsedAccessToken = parsedAccessToken - loginState.expireAt = parsedAccessToken.exp * 1000 - } - - await this.loginStateProvider.put( - loginStateKey(this.options.appId), - loginState - ) - return loginState - } - - private async exchangeToken( - code: string, - redirectUri: string, - codeVerifier: string, - nonce: string - ) { - const tokenParam: PKCETokenParams = { - grant_type: 'authorization_code', - code, - code_verifier: codeVerifier as string, - client_id: this.options.appId, - redirect_uri: redirectUri - } - - const { data: tokenRes } = (await axiosPost( - `${this.domain}/oidc/token`, - createQueryParams(tokenParam), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - } - )) as { data: OIDCTokenResponse } - - return this.saveLoginState({ - idToken: tokenRes.id_token, - accessToken: tokenRes.access_token, - nonce - }) - } - - private async handleOIDCWebMsgResponse( - res: OIDCWebMessageResponse, - nonce: string, - // 只有 PKCE 会用下面两个参数 - redirectUri?: string, - codeVerifier?: string - ) { - if (this.options.useImplicitMode) { - // implicit flow - if ( - (this.options.implicitResponseType.includes('token') && + const { accessToken, idToken } = params + const loginState: LoginState = { + accessToken: accessToken, + idToken: idToken, + timestamp: Date.now() + } + + if (idToken) { + const parsedIdToken: IDToken = parseToken(idToken).body + loginState.parsedIdToken = parsedIdToken + loginState.expireAt = parsedIdToken.exp * 1000 + + if (params.nonce && parsedIdToken.nonce !== params.nonce) { + throw new Error('nonce 验证失败') + } + } + + if (accessToken) { + const parsedAccessToken: AccessToken = parseToken(accessToken).body + loginState.parsedAccessToken = parsedAccessToken + loginState.expireAt = parsedAccessToken.exp * 1000 + } + + await this.loginStateProvider.put( + loginStateKey(this.options.appId), + loginState + ) + return loginState + } + + private async exchangeToken( + code: string, + redirectUri: string, + codeVerifier: string, + nonce: string + ) { + const tokenParam: PKCETokenParams = { + grant_type: 'authorization_code', + code, + code_verifier: codeVerifier as string, + client_id: this.options.appId, + redirect_uri: redirectUri + } + + const { data: tokenRes } = (await axiosPost( + `${this.domain}/oidc/token`, + createQueryParams(tokenParam), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + )) as { data: OIDCTokenResponse } + + return this.saveLoginState({ + idToken: tokenRes.id_token, + accessToken: tokenRes.access_token, + nonce + }) + } + + private async handleOIDCWebMsgResponse( + res: OIDCWebMessageResponse, + nonce: string, + // 只有 PKCE 会用下面两个参数 + redirectUri?: string, + codeVerifier?: string + ) { + if (this.options.useImplicitMode) { + // implicit flow + if ( + (this.options.implicitResponseType.includes('token') && typeof res.accessToken !== 'string') || (this.options.implicitResponseType.includes('id_token') && typeof res.idToken !== 'string') - ) { - throw new Error('无效的 Token 返回值') - } - - return this.saveLoginState({ - accessToken: res.accessToken, - idToken: res.idToken, - nonce - }) - } - - // PKCE code flow - if (typeof res.code !== 'string') { - throw new Error('无效的 Code 返回值') - } - - if (!redirectUri || !codeVerifier) { - // should never happen - throw new Error() - } - - return this.exchangeToken(res.code, redirectUri, codeVerifier, nonce) - } - - private resolveCallbackParams() { - const paramSource: string = + ) { + throw new Error('无效的 Token 返回值') + } + + return this.saveLoginState({ + accessToken: res.accessToken, + idToken: res.idToken, + nonce + }) + } + + // PKCE code flow + if (typeof res.code !== 'string') { + throw new Error('无效的 Code 返回值') + } + + if (!redirectUri || !codeVerifier) { + // should never happen + throw new Error() + } + + return this.exchangeToken(res.code, redirectUri, codeVerifier, nonce) + } + + private resolveCallbackParams() { + const paramSource: string = this.options.redirectResponseMode === 'fragment' - ? window.location.hash - : window.location.search - if (!paramSource) { - return null - } - - const paramDict: StrDict = Object.create(null) - paramSource - .substring(1) - .split('&') - .forEach(item => { - const [key, val] = item.split('=') - paramDict[key] = val - }) - - return paramDict - } + ? window.location.hash + : window.location.search + if (!paramSource) { + return null + } + + const paramDict: StrDict = Object.create(null) + paramSource + .substring(1) + .split('&') + .forEach(item => { + const [key, val] = item.split('=') + paramDict[key] = val + }) + + return paramDict + } } diff --git a/packages/web/src/axios.ts b/packages/web/src/axios.ts index e8677900a..e2b035e2d 100644 --- a/packages/web/src/axios.ts +++ b/packages/web/src/axios.ts @@ -3,47 +3,47 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios' import { version } from '../package.json' function isAxiosError(e: any): e is AxiosError { - return e.isAxiosError + return e.isAxiosError } async function axiosPromiseWrapper(p: Promise) { - try { - return await p - } catch (e) { - if (isAxiosError(e)) { - if (e.response?.data?.error) { - const { error, error_description } = e.response.data - throw new Error(`认证服务器返回错误 ${error}: ${error_description}`) - } - } - throw e - } + try { + return await p + } catch (e) { + if (isAxiosError(e)) { + if (e.response?.data?.error) { + const { error, error_description } = e.response.data + throw new Error(`认证服务器返回错误 ${error}: ${error_description}`) + } + } + throw e + } } export async function axiosGet( - url: string, - options?: AxiosRequestConfig + url: string, + options?: AxiosRequestConfig ) { - const _options = mergeOptions(options) - return axiosPromiseWrapper(axios.get(url, _options)) + const _options = mergeOptions(options) + return axiosPromiseWrapper(axios.get(url, _options)) } export async function axiosPost( - url: string, - data?: any, - options?: AxiosRequestConfig + url: string, + data?: any, + options?: AxiosRequestConfig ) { - const _options = mergeOptions(options) - return axiosPromiseWrapper(axios.post(url, data, _options)) + const _options = mergeOptions(options) + return axiosPromiseWrapper(axios.post(url, data, _options)) } function mergeOptions (options?: AxiosRequestConfig): AxiosRequestConfig { - const _options = Object.assign({}, options || {}, { - headers: { - ...options?.headers, - 'x-authing-request-from': 'sdk-web', - 'x-authing-sdk-version': version - } - }) - return _options + const _options = Object.assign({}, options || {}, { + headers: { + ...options?.headers, + 'x-authing-request-from': 'sdk-web', + 'x-authing-sdk-version': version + } + }) + return _options } diff --git a/packages/web/src/storage/InMemoryStorgeProvider.ts b/packages/web/src/storage/InMemoryStorgeProvider.ts index 3b1cbc203..326c0a29a 100644 --- a/packages/web/src/storage/InMemoryStorgeProvider.ts +++ b/packages/web/src/storage/InMemoryStorgeProvider.ts @@ -2,17 +2,17 @@ import { MayBePromise } from '../types' import { StorageProvider } from './interface' export class InMemoryStorageProvider implements StorageProvider { - private readonly storage = Object.create(null) + private readonly storage = Object.create(null) - get(key: string): MayBePromise { - return this.storage[key] ?? null - } + get(key: string): MayBePromise { + return this.storage[key] ?? null + } - put(key: string, value: T): MayBePromise { - this.storage[key] = value - } + put(key: string, value: T): MayBePromise { + this.storage[key] = value + } - delete(key: string): MayBePromise { - delete this.storage[key] - } + delete(key: string): MayBePromise { + delete this.storage[key] + } } diff --git a/packages/web/src/storage/LocalStorageProvider.ts b/packages/web/src/storage/LocalStorageProvider.ts index 61a0c612a..275825968 100644 --- a/packages/web/src/storage/LocalStorageProvider.ts +++ b/packages/web/src/storage/LocalStorageProvider.ts @@ -2,19 +2,19 @@ import { MayBePromise } from '../types' import { StorageProvider } from './interface' export class LocalStorageProvider implements StorageProvider { - get(key: string): MayBePromise { - const jsonItem = localStorage.getItem(key) - if (jsonItem === null) { - return null - } - return JSON.parse(jsonItem) as T - } + get(key: string): MayBePromise { + const jsonItem = localStorage.getItem(key) + if (jsonItem === null) { + return null + } + return JSON.parse(jsonItem) as T + } - put(key: string, value: T): MayBePromise { - localStorage.setItem(key, JSON.stringify(value)) - } + put(key: string, value: T): MayBePromise { + localStorage.setItem(key, JSON.stringify(value)) + } - delete(key: string): MayBePromise { - localStorage.removeItem(key) - } + delete(key: string): MayBePromise { + localStorage.removeItem(key) + } } diff --git a/packages/web/src/storage/NullStorageProvider.ts b/packages/web/src/storage/NullStorageProvider.ts index 4ae4118a2..0f9836df3 100644 --- a/packages/web/src/storage/NullStorageProvider.ts +++ b/packages/web/src/storage/NullStorageProvider.ts @@ -2,15 +2,15 @@ import { MayBePromise } from '../types' import { StorageProvider } from './interface' export class NullStorageProvider implements StorageProvider { - get(): MayBePromise { - return null - } + get(): MayBePromise { + return null + } - put(): MayBePromise { - // null - } + put(): MayBePromise { + // null + } - delete(): MayBePromise { - // null - } + delete(): MayBePromise { + // null + } } diff --git a/packages/web/src/storage/SessionStorageProvider.ts b/packages/web/src/storage/SessionStorageProvider.ts index 28bf1dcfc..8fb0a1dc9 100644 --- a/packages/web/src/storage/SessionStorageProvider.ts +++ b/packages/web/src/storage/SessionStorageProvider.ts @@ -2,19 +2,19 @@ import { MayBePromise } from '../types' import { StorageProvider } from './interface' export class SessionStorageProvider implements StorageProvider { - get(key: string): MayBePromise { - const jsonItem = sessionStorage.getItem(key) - if (jsonItem === null) { - return null - } - return JSON.parse(jsonItem) as T - } + get(key: string): MayBePromise { + const jsonItem = sessionStorage.getItem(key) + if (jsonItem === null) { + return null + } + return JSON.parse(jsonItem) as T + } - put(key: string, value: T): MayBePromise { - sessionStorage.setItem(key, JSON.stringify(value)) - } + put(key: string, value: T): MayBePromise { + sessionStorage.setItem(key, JSON.stringify(value)) + } - delete(key: string): MayBePromise { - sessionStorage.removeItem(key) - } + delete(key: string): MayBePromise { + sessionStorage.removeItem(key) + } } diff --git a/packages/web/src/utils.ts b/packages/web/src/utils.ts index 7760593e4..c4bda877d 100644 --- a/packages/web/src/utils.ts +++ b/packages/web/src/utils.ts @@ -2,121 +2,121 @@ import { STORAGE_KEY_PREFIX } from './constants' import { StrDict } from './types' export function createQueryParams(params: any) { - return Object.keys(params) - .filter(k => params[k] !== null && params[k] !== undefined) - .map( - k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k] as string) - ) - .join('&') + return Object.keys(params) + .filter(k => params[k] !== null && params[k] !== undefined) + .map( + k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k] as string) + ) + .join('&') } export function loginStateKey(appId: string) { - return [STORAGE_KEY_PREFIX, appId, 'login-state'].join(':') + return [STORAGE_KEY_PREFIX, appId, 'login-state'].join(':') } export function transactionKey(appId: string, state: string) { - return [STORAGE_KEY_PREFIX, appId, 'tx', state].join(':') + return [STORAGE_KEY_PREFIX, appId, 'tx', state].join(':') } export function getCrypto() { - //ie 11.x uses msCrypto - return (window.crypto || (window as any).msCrypto) as Crypto + //ie 11.x uses msCrypto + return (window.crypto || (window as any).msCrypto) as Crypto } export function getCryptoSubtle() { - const crypto = getCrypto() - //safari 10.x uses webkitSubtle - return crypto.subtle || (crypto as any).webkitSubtle + const crypto = getCrypto() + //safari 10.x uses webkitSubtle + return crypto.subtle || (crypto as any).webkitSubtle } export function createRandomString(length: number) { - const charset = + const charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' - const randomValues = Array.from( - getCrypto().getRandomValues(new Uint8Array(length)) - ) - return randomValues.map(v => charset[v % charset.length]).join('') + const randomValues = Array.from( + getCrypto().getRandomValues(new Uint8Array(length)) + ) + return randomValues.map(v => charset[v % charset.length]).join('') } export function string2Buf(str: string) { - const buffer: number[] = [] - for (let i = 0; i < str.length; ++i) { - buffer.push(str.charCodeAt(i)) - } - return new Uint8Array(buffer) + const buffer: number[] = [] + for (let i = 0; i < str.length; ++i) { + buffer.push(str.charCodeAt(i)) + } + return new Uint8Array(buffer) } function buf2Base64Url(buffer: ArrayBuffer) { - const ie11SafeInput = new Uint8Array(buffer) - let binary = '' - for (let i = 0; i < ie11SafeInput.byteLength; ++i) { - binary += String.fromCharCode(ie11SafeInput[i]) - } - const base64 = window.btoa(binary) - const charMapping: StrDict = { '+': '-', '/': '_', '=': '' } - return base64.replace(/[+/=]/g, (ch: string) => charMapping[ch]) + const ie11SafeInput = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < ie11SafeInput.byteLength; ++i) { + binary += String.fromCharCode(ie11SafeInput[i]) + } + const base64 = window.btoa(binary) + const charMapping: StrDict = { '+': '-', '/': '_', '=': '' } + return base64.replace(/[+/=]/g, (ch: string) => charMapping[ch]) } export async function genPKCEPair(algorithm = 'SHA-256') { - // 规定最少 43 个字符 - const codeVerifier = createRandomString(43) - const hash = await getCryptoSubtle().digest( - algorithm, - string2Buf(codeVerifier) - ) - const codeChallenge = buf2Base64Url(hash) - return { codeChallenge, codeVerifier } + // 规定最少 43 个字符 + const codeVerifier = createRandomString(43) + const hash = await getCryptoSubtle().digest( + algorithm, + string2Buf(codeVerifier) + ) + const codeChallenge = buf2Base64Url(hash) + return { codeChallenge, codeVerifier } } export function domainC14n(domain: string) { - const domainExp = + const domainExp = /^((?:http)|(?:https):\/\/)?((?:[\w-_]+)(?:\.[\w-_]+)+)(?:\/.*)?$/ - const matchRes = domainExp.exec(domain) - if (matchRes && matchRes[2]) { - return `${matchRes[1] ?? 'https://'}${matchRes[2]}` - } - throw Error(`无效的域名配置: ${domain}`) + const matchRes = domainExp.exec(domain) + if (matchRes && matchRes[2]) { + return `${matchRes[1] ?? 'https://'}${matchRes[2]}` + } + throw Error(`无效的域名配置: ${domain}`) } export function parseToken(token: string) { - let [header, body, sig] = token.split('.') - if (!sig) { - throw new Error('无效的 Token 格式') - } - - const headerObj = JSON.parse(window.atob(header)) - if (headerObj.enc) { - throw new Error( - '本 SDK 目前不支持处理加密 Token, 请在应用配置中关闭「ID Token 加密」功能' - ) - } - - body = body.replace(/-/g, '+').replace(/_/g, '/') - body = decodeURIComponent( - window - .atob(body) - .split('') - .map(function (c) { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) - }) - .join('') - ) - - return { - header: headerObj, - body: JSON.parse(body) - } + let [header, body, sig] = token.split('.') + if (!sig) { + throw new Error('无效的 Token 格式') + } + + const headerObj = JSON.parse(window.atob(header)) + if (headerObj.enc) { + throw new Error( + '本 SDK 目前不支持处理加密 Token, 请在应用配置中关闭「ID Token 加密」功能' + ) + } + + body = body.replace(/-/g, '+').replace(/_/g, '/') + body = decodeURIComponent( + window + .atob(body) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + }) + .join('') + ) + + return { + header: headerObj, + body: JSON.parse(body) + } } export function isIE() { - if ( - window.navigator.userAgent.indexOf('MSIE') >= 1 || + if ( + window.navigator.userAgent.indexOf('MSIE') >= 1 || (window.navigator.userAgent.indexOf('Trident') >= 1 && window.navigator.userAgent.indexOf('rv') >= 1) || window.navigator.userAgent.indexOf('Edge') >= 1 - ) { - return true - } + ) { + return true + } - return false + return false } diff --git a/packages/weixin-official-account/src/index.ts b/packages/weixin-official-account/src/index.ts index 42cfc2326..ee251bf68 100644 --- a/packages/weixin-official-account/src/index.ts +++ b/packages/weixin-official-account/src/index.ts @@ -1,68 +1,68 @@ import { AuthingWxmpOptions } from './types' export class AuthingWxmp { - private options: AuthingWxmpOptions + private options: AuthingWxmpOptions - constructor(options: AuthingWxmpOptions) { - this.options = options + constructor(options: AuthingWxmpOptions) { + this.options = options - this.validateKeys() - } + this.validateKeys() + } - validateKeys() { - const keys = ['appId', 'host', 'identifier', 'redirectUrl'] + validateKeys() { + const keys = ['appId', 'host', 'identifier', 'redirectUrl'] - keys.forEach(key => { - const _key = key as keyof AuthingWxmpOptions - if (!this.options[_key]) { - throw new Error(`"${_key}" is not provided`) - } - }) - } + keys.forEach(key => { + const _key = key as keyof AuthingWxmpOptions + if (!this.options[_key]) { + throw new Error(`"${_key}" is not provided`) + } + }) + } - checkWechatUA(): boolean { - const ua = window.navigator.userAgent.toLowerCase() + checkWechatUA(): boolean { + const ua = window.navigator.userAgent.toLowerCase() - const matched = ua.match(/MicroMessenger/i) + const matched = ua.match(/MicroMessenger/i) - if (matched && matched[0] === 'micromessenger') { - return true - } + if (matched && matched[0] === 'micromessenger') { + return true + } - return false - } + return false + } - getAuthorizationUrl() { - const { appId, host, identifier, redirectUrl } = this.options + getAuthorizationUrl() { + const { appId, host, identifier, redirectUrl } = this.options - const queryObject = { - app_id: appId, - redirect_url: redirectUrl - } + const queryObject = { + app_id: appId, + redirect_url: redirectUrl + } - return `${host}/connections/social/${identifier}?${new URLSearchParams( - queryObject - ).toString()}` - } + return `${host}/connections/social/${identifier}?${new URLSearchParams( + queryObject + ).toString()}` + } - getUserInfo(search: string) { - search = search || window.location.search + getUserInfo(search: string) { + search = search || window.location.search - const urlParams = new URLSearchParams(search) - const code = urlParams.get('code') - const message = urlParams.get('message') - let userInfo = urlParams.get('data') + const urlParams = new URLSearchParams(search) + const code = urlParams.get('code') + const message = urlParams.get('message') + let userInfo = urlParams.get('data') - const ok = code && +code === 200 + const ok = code && +code === 200 - if (userInfo) { - userInfo = JSON.parse(userInfo) - } + if (userInfo) { + userInfo = JSON.parse(userInfo) + } - return { - ok, - message, - userInfo - } - } + return { + ok, + message, + userInfo + } + } }