diff --git a/.eslintrc b/.eslintrc new file mode 100755 index 0000000..32a503e --- /dev/null +++ b/.eslintrc @@ -0,0 +1,27 @@ +{ + "parser": "babel-eslint", + "extends": "airbnb", + "globals": { + "fetch": true, + "__DEV__": true + }, + "env": { + "browser": true, + "node": true + }, + "rules": { + "import/no-extraneous-dependencies": 0, + "import/no-unresolved": 0, + "import/prefer-default-export": 0, + "react/prefer-stateless-function": 0, + "max-len": ["error",{ "code":120 }] + }, + "settings": { + "import/resolver": { + "node": { + "extensions": [".js", ".android.js", ".ios.js"] + } + } + }, + "plugins": ["react", "react-native"] +} diff --git a/README.md b/README.md index d841351..ef81277 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,58 @@ -# React Native Login - -React Native Login is a module for [React Native](https://facebook.github.io/react-native/) for implementing lightweight universal authentication using [Keycloak](http://keycloak.org) - - -See [Simple social login for React Native apps](https://medium.com/@ak1394/simple-social-login-for-react-native-apps-71279bf80ffc) for details. +# react-native-login-keycloak +This is a fork of ak1394's React-Native-Login module. It's a version that I'm planning to maintenance more than it's been with ak1394. ## Documentation -- [Install](https://github.com/ak1394/react-native-login#install) -- [Usage](https://github.com/ak1394/react-native-login#usage) -- [Example](https://github.com/ak1394/react-native-login#example) -- [License](https://github.com/ak1394/react-native-login#license) +- [Install](https://github.com/mahomahoxd/react-native-login#install) +- [Usage](https://github.com/mahomahoxd/react-native-login#usage) ## Install ```shell -npm install --save react-native-login +npm i --save react-native-login-keycloak ``` ## Usage ### App configuration -Please configure [Linking](https://facebook.github.io/react-native/docs/linking.html) module, including steps for handling Universal links. +Please configure [Linking](https://facebook.github.io/react-native/docs/linking.html) module, including steps for handling Universal links (This might get changed due to not being able to close the tab on leave, ending up with a lot of tabs in the browser). -Also, add applinks: entry to Associated Domains Capability of your app. +Also, add the applinks: entry to the Associated Domains Capability of your app. ### Imports ```js -import Login from 'react-native-login'; +import Login from 'react-native-login-keycloak'; ``` -### Checking if user is logged in +### Checking if tokens are saved on the device ```js -Login.tokens().then(tokens => { - console.log(tokens); -}); +const gatheredTokens = await Login.getTokens(); +console.log(gatheredTokens); // Prints: // // { access_token: '...', refresh_token: '...', id_token: '...', ...} +// OR +// undefined ``` ### Login - ```js const config = { url: 'https:///auth', realm: '', - client_id: '', - redirect_uri: 'https:///success.html', - appsite_uri: 'https:///app.html', - kc_idp_hint: 'facebook', + clientId: '', + redirectUri: 'https:///success.html', + appsiteUri: 'https:///app.html', + kcIdpHint: 'facebook', // *optional* }; -Login.start(config).then(tokens => { +Login.startLoginProcess(config).then(tokens => { console.log(tokens); }); @@ -67,44 +61,40 @@ Login.start(config).then(tokens => { // { access_token: '...', refresh_token: '...', id_token: '...', ...} ``` -Initiates login flow. Upon successfull completion, saves and returns a set of tokens. - -### Logout +Logging in by the startLoginProcess function will save it in the AsyncStorage, whereas after its been successful, getTokens will get the most recent tokens that are saved and you can then use it to authenticate against a backend. +### Refreshing the token ```js -Login.end(); +const refreshedTokens = await Login.refreshToken(); +console.log(refreshTokens); +// Prints: +// +// { access_token: '...', refresh_token: '...', id_token: '...', ...} +// OR +// undefined ``` -Removes stored tokens. Subsequent calls to Login.tokens() will return null. -## Example -Please see the example app [react-native-login-example](https://github.com/ak1394/react-native-login-example) +### Retrieving logged in user info +```js +const loggedInUser = await Login.retrieveUserInfo(); +console.log(loggedInUser); -## License +// Prints: +// +// { sub: '...',name: '... ',preferred_username: '...',given_name: '...' } -The MIT License (MIT) -===================== +// OR +// undefined +``` -Copyright © `2016` `Anton Krasovsky` -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the “Software”), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: +### Logout -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +```js +Login.logoutKc(); +``` +Removes stored tokens. Will also do a Keycloak call to log the user out. Returns true on logout, else false. Subsequent calls to Login.tokens() will return null. -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +If you got any improvements feel free to make a pull request or suggestion. diff --git a/package.json b/package.json index 4ffc316..c4bcf93 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "react-native-login", - "version": "0.0.1-alpha.2", - "description": "React Native module for lightweight universal authentication using Keycloak", + "name": "react-native-login-keycloak", + "version": "1.0.2", + "description": "React Native module for authentication between a client and the keycloak server.", "main": "src/index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", - "url": "git+https://github.com/ak1394/react-native-login.git" + "url": "git+https://github.com/mahomahoxd/react-native-login-keycloak.git" }, "keywords": [ "auth", @@ -20,15 +20,28 @@ "social", "openid" ], - "author": "Anton Krasovsky", + "author": [ + "Maho Murtic", + "Anton Krekovsky" + ], "license": "MIT", "bugs": { - "url": "https://github.com/ak1394/react-native-login/issues" + "url": "https://github.com/mahomahoxd/react-native-login-keycloak/issues" }, - "homepage": "https://github.com/ak1394/react-native-login#readme", + "homepage": "https://github.com/mahomahoxd/react-native-login-keycloak#readme", "dependencies": { "base-64": "^0.1.0", - "query-string": "^4.2.3", - "react-native-uuid": "^1.4.8" + "eslint-plugin-react": "^7.5.1", + "query-string": "^5.0.1", + "uuid": "^3.1.0" + }, + "devDependencies": { + "babel-eslint": "^8.0.3", + "eslint": "^4.13.1", + "eslint-config-airbnb": "^16.1.0", + "eslint-plugin-import": "^2.8.0", + "eslint-plugin-jsx-a11y": "^6.0.3", + "eslint-plugin-react": "^7.5.1", + "eslint-plugin-react-native": "^3.2.0" } } diff --git a/src/Login.js b/src/Login.js new file mode 100644 index 0000000..f5db52c --- /dev/null +++ b/src/Login.js @@ -0,0 +1,191 @@ +import { Linking } from 'react-native'; +import * as querystring from 'query-string'; +import uuidv4 from 'uuid/v4'; + +export class Login { + state; + conf; + tokenStorage; + + constructor() { + this.state = {}; + this.onOpenURL = this.onOpenURL.bind(this); + Linking.addEventListener('url', this.onOpenURL); + + this.props = { + requestOptions: { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'GET', + body: undefined, + }, + url: '', + }; + } + + getTokens() { + return this.tokenStorage.loadTokens(); + } + + startLoginProcess(conf) { + this.setConf(conf); + return new Promise(((resolve, reject) => { + const { url, state } = this.getLoginURL(); + this.state = { + ...this.state, + resolve, + reject, + state, + }; + Linking.openURL(url); + })); + } + + setConf(conf) { + if (conf) { + this.conf = conf; + } + } + + async logoutKc() { + const { clientId } = this.conf; + const savedTokens = await this.getTokens(); + if (!savedTokens) { + return undefined; + } + + this.props.url = `${this.getRealmURL()}/protocol/openid-connect/logout`; + + this.setRequestOptions( + 'POST', + querystring.stringify({ client_id: clientId, refresh_token: savedTokens.refresh_token }), + ); + + const fullResponse = await fetch(this.props.url, this.props.requestOptions); + + if (fullResponse.ok) { + this.tokenStorage.clearTokens(); + return true; + } + return false; + } + + onOpenURL(event) { + if (event.url.startsWith(this.conf.appsiteUri)) { + const { + state, + code, + } = querystring.parse(querystring.extract(event.url)); + if (this.state.state === state) { + this.retrieveTokens(code); + } + } + } + + + async retrieveTokens(code) { + const { redirectUri, clientId } = this.conf; + this.props.url = `${this.getRealmURL()}/protocol/openid-connect/token`; + + this.setRequestOptions( + 'POST', + querystring.stringify({ + grant_type: 'authorization_code', redirect_uri: redirectUri, client_id: clientId, code, + }), + ); + + const fullResponse = await fetch(this.props.url, this.props.requestOptions); + const jsonResponse = await fullResponse.json(); + if (fullResponse.ok) { + this.tokenStorage.saveTokens(jsonResponse); + this.state.resolve(jsonResponse); + } else { + this.state.reject(jsonResponse); + } + } + + async retrieveUserInfo() { + const savedTokens = await this.getTokens(); + if (savedTokens) { + this.props.url = `${this.getRealmURL()}/protocol/openid-connect/userinfo`; + + this.setHeader('Authorization', `Bearer ${savedTokens.access_token}`); + this.setRequestOptions('GET'); + + const fullResponse = await fetch(this.props.url, this.props.requestOptions); + if (fullResponse.ok) { + return fullResponse.json(); + } + } + return undefined; + } + + async refreshToken() { + const savedTokens = await this.getTokens(); + if (!savedTokens) { + return undefined; + } + + const { clientId } = this.conf; + this.props.url = `${this.getRealmURL()}/protocol/openid-connect/token`; + + this.setRequestOptions('POST', querystring.stringify({ + grant_type: 'refresh_token', + refresh_token: savedTokens.refresh_token, + client_id: encodeURIComponent(clientId), + })); + + const fullResponse = await fetch(this.props.url, this.props.requestOptions); + if (fullResponse.ok) { + const jsonResponse = await fullResponse.json(); + this.tokenStorage.saveTokens(jsonResponse); + return jsonResponse; + } + return undefined; + } + + getRealmURL() { + const { url, realm } = this.conf; + const slash = url.endsWith('/') ? '' : '/'; + return `${url + slash}realms/${encodeURIComponent(realm)}`; + } + + getLoginURL() { + const { redirectUri, clientId, kcIdpHint,options } = this.conf; + const responseType = 'code'; + const state = uuidv4(); + const scope = 'openid'; + const url = `${this.getRealmURL()}/protocol/openid-connect/auth?${querystring.stringify({ + scope, + kc_idp_hint: kcIdpHint, + redirect_uri: redirectUri, + client_id: clientId, + response_type: responseType, + options:options, + state, + })}`; + + return { + url, + state, + }; + } + + setTokenStorage(tokenStorage) { + this.tokenStorage = tokenStorage; + } + + setRequestOptions(method, body) { + this.props.requestOptions = { + ...this.props.requestOptions, + method, + body, + }; + } + + setHeader(key, value) { + this.props.requestOptions.headers[key] = value; + } +} diff --git a/src/TokenStorage.js b/src/TokenStorage.js new file mode 100644 index 0000000..a043e79 --- /dev/null +++ b/src/TokenStorage.js @@ -0,0 +1,21 @@ +import { AsyncStorage } from 'react-native'; + +export class TokenStorage { + key; + constructor(key) { + this.key = key; + } + + saveTokens(tokens) { + return AsyncStorage.setItem(this.key, JSON.stringify(tokens)); + } + + async loadTokens() { + const tokens = await AsyncStorage.getItem(this.key); + return (tokens) ? JSON.parse(tokens) : undefined; + } + + clearTokens() { + return AsyncStorage.removeItem(this.key); + } +} diff --git a/src/util.js b/src/Utils.js similarity index 75% rename from src/util.js rename to src/Utils.js index 7e125e8..a717869 100644 --- a/src/util.js +++ b/src/Utils.js @@ -7,18 +7,18 @@ export function decodeToken(token) { str = str.replace('/_/g', '/'); switch (str.length % 4) { case 0: - break; + break; case 2: str += '=='; - break; + break; case 3: str += '='; - break; + break; default: - throw 'Invalid token'; + throw new Error('Invalid token'); } - str = (str + '===').slice(0, str.length + (str.length % 4)); + str = (`${str}===`).slice(0, str.length + (str.length % 4)); str = str.replace(/-/g, '+').replace(/_/g, '/'); str = decodeURIComponent(escape(base64.decode(str))); diff --git a/src/index.js b/src/index.js index 9f60b65..3311afd 100644 --- a/src/index.js +++ b/src/index.js @@ -1,124 +1,7 @@ -import { AsyncStorage, Linking } from 'react-native'; -import * as querystring from 'query-string'; -import uuid from 'react-native-uuid'; -import {decodeToken} from './util'; - -class TokenStorage { - constructor(key) { - this.key = key; - } - - saveTokens(tokens) { - return AsyncStorage.setItem(this.key, JSON.stringify(tokens)); - } - - loadTokens() { - return new Promise((resolve, reject) => { - AsyncStorage.getItem(this.key).then(value => resolve(JSON.parse(value))); - }); - } - - clearTokens() { - return AsyncStorage.removeItem(this.key); - } -} - -class Login { - constructor() { - this.state = {}; - this.onOpenURL = this.onOpenURL.bind(this); - Linking.addEventListener('url', this.onOpenURL); - } - - tokens() { - return this.tokenStorage.loadTokens(); - } - - start(conf) { - this.conf = conf; - return new Promise(function(resolve, reject) { - const {url, state} = this.getLoginURL(); - this.state = { - ...this.state, - resolve, - reject, - state, - }; - - Linking.openURL(url); - }.bind(this)); - } - - end() { - return this.tokenStorage.clearTokens(); - } - - onOpenURL(event) { - if(event.url.startsWith(this.conf.appsite_uri)) { - const {state, code} = querystring.parse(querystring.extract(event.url)); - if(this.state.state === state) { - this.retrieveTokens(code); - } - } - } - - retrieveTokens(code) { - const {redirect_uri, client_id} = this.conf; - const url = this.getRealmURL() + '/protocol/openid-connect/token'; - - const headers = new Headers(); - headers.set('Content-Type', 'application/x-www-form-urlencoded'); - - const body = querystring.stringify({ - grant_type: 'authorization_code', - redirect_uri, - client_id, - code - }); - - fetch(url, {method: 'POST', headers, body}).then(response => { - response.json().then(json => { - if(json.error) { - this.state.reject(json); - } else { - this.tokenStorage.saveTokens(json); - this.state.resolve(json); - } - }); - }); - } - - decodeToken(token) { - return decodeToken(token); - } - - getRealmURL() { - const {url, realm} = this.conf; - const slash = url.endsWith('/') ? '' : '/'; - return url + slash + 'realms/' + encodeURIComponent(realm); - } - - getLoginURL() { - const {redirect_uri, client_id, kc_idp_hint} = this.conf; - const response_type = 'code'; - const state = uuid.v4(); - const url = this.getRealmURL() + '/protocol/openid-connect/auth?' + querystring.stringify({ - kc_idp_hint, - redirect_uri, - client_id, - response_type, - state, - }); - - return {url, state}; - } - - setTokenStorage(tokenStorage) { - this.tokenStorage = tokenStorage; - } -} +import { Login } from './Login'; +import { TokenStorage } from './TokenStorage'; const login = new Login(); -login.setTokenStorage(new TokenStorage('react-native-token-storage')); +login.setTokenStorage(new TokenStorage('react-native-keycloak-tokens')); export default login;