├── .gitignore ├── .sample.config.json ├── templates └── base.html ├── requirements.txt ├── README.md └── scim-server.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *.pyc 3 | *~ 4 | .DS_Store 5 | .exports 6 | .config.json 7 | -------------------------------------------------------------------------------- /.sample.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "aws": { 4 | "user_pool_id": "us-west-2_xxxxxx", 5 | "access_key": "access-key-of-IAM-user-with-Cognito-permissions", 6 | "secret_key": "the-secret-key-of-IAM-user-with-Cognito-permissions" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Okta SCIM to Cognito Example 5 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.4.4 2 | botocore==1.5.89 3 | Click==7.0 4 | docutils==0.13.1 5 | Flask==1.0.2 6 | Flask-SocketIO==2.9.1 7 | Flask-SQLAlchemy==2.2 8 | futures==3.1.1 9 | gunicorn==19.7.1 10 | itsdangerous==1.1.0 11 | Jinja2==2.10.1 12 | jmespath==0.9.3 13 | MarkupSafe==1.1.0 14 | psycopg2==2.7.3 15 | python-dateutil==2.6.1 16 | python-engineio==3.9.0 17 | python-socketio==1.8.0 18 | s3transfer==0.1.10 19 | six==1.10.0 20 | SQLAlchemy==1.3.0 21 | Werkzeug==0.15.3 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | Based on the [okta-scim-beta](https://github.com/oktadeveloper/okta-scim-beta) project, 3 | this example shows how to use Okta's cloud-based SCIM connector to automatically provision 4 | and deprovision users managed by Okta into a single Cognito user-pool. 5 | 6 | This example code was written for **Python 2.7** and uses the excellent [Boto python interface 7 | to AWS](https://boto3.readthedocs.io/en/latest/guide/quickstart.html), which has support for Cognito. 8 | 9 | 10 | # Running the sample project 11 | 12 | `git checkout` this repository, then: 13 | 14 | 1. `cd` to the directory you just checked out: 15 | 16 | $ cd okta-scim-to-cognito 17 | 2. Create an isolated Python environment named "venv" using [virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/): 18 | 19 | $ virtualenv venv 20 | 3. Next, activate the newly created virtualenv: 21 | 22 | $ source venv/bin/activate 23 | 4. Then, install the dependencies for the sample SCIM server using 24 | Python's ["pip" package manager](https://en.wikipedia.org/wiki/Pip_%28package_manager%29): 25 | 26 | $ pip install -r requirements.txt 27 | 5. Rename **.sample.config.json** to **.config.json** 28 | 29 | $ mv .sample.config.json .config.json 30 | 31 | 6. Edit **.config.json* and provide the values for **user_pool_id**, **access_key** and **secret_key**. 32 | 33 | This project only provisions into a single user-pool, so you must supply the name for 34 | that user-pool. 35 | 36 | Boto requires AWS credentials in order to make requests. Create security credentials 37 | for an IAM user that has the **AmazonCognitoPowerUser** policy and enter the *access_key* 38 | and *secret_key* values from the credentials into this file. 39 | 40 | 7. Start the example SCIM server using this command: 41 | 42 | $ python scim-server.py 43 | 44 | 8. Make this service reachable by the internet with [ngrok](https://ngrok.com/). 45 | Download and install ngrok, then start it up 46 | 47 | $ ngrok http 5000 48 | 49 | ## AWS Cognito User-pool requirements 50 | The sample project provisions and deprovisions users into a single congnito user-pool. 51 | At the minimum, the pool must have the following attributes: 52 | 1. email 53 | 2. name 54 | 55 | For simplicity's sake, the sample project creates users staged with 56 | the temporary password '123456'. In your user-pool's policy configuration: 57 | 1. Set password minimum length = 6 58 | 2. Do not require special characters, uppercase or lowercase letters 59 | 60 | 61 | 62 | ## Okta setup 63 | Follow the steps in [SCIM App Wizard Guide](https://help.okta.com/en/prod/Content/Topics/Apps/Apps_App_Integration_Wizard.htm#The) 64 | to create the SCIM Application. 65 | * In the SCIM connector base URL field, enter `https://your-ngrok-https-forwarding-url/scim/v2` 66 | 67 | e.g `https://d0aafa9e.ngrok.io/scim/v2` 68 | * In the Unique identifier field for users, enter the value `userName` 69 | 70 | * Change Authentication Mode to `HTTP Header`. Enter any random value for HTTP Header, Authorization Token. The field is required, 71 | but the sample project will not be using it. In production, protect your 72 | endpoints with an API Key. (Or Basic Auth or OAuth2. Recall from the previous steps, 73 | there are SCIM templates for each of the 3 types of auth protocols) 74 | 75 | * Click [Test Connector Credentials]. If you receive a success message, your configuration is complete. 76 | Now you can assign and unassign users to your app and watch it automatically provision and 77 | deprovision users to your Cognito user-pool. 78 | 79 | 80 | # Appendix 81 | ## Understanding of User Provisioning in Okta 82 | 83 | Okta is a universal directory with the main focus in storing 84 | identity related information. Users can be created in Okta directly 85 | as local users or imported from external system like Active 86 | Directory or a [Human Resource Management Software](https://en.wikipedia.org/wiki/Category:Human_resource_management_software) system. 87 | 88 | An Okta user schema contains many different user attributes, 89 | but always contains a user name, first name, last name, and 90 | email address. This schema can be extended. 91 | 92 | Okta user attributes can be mapped from a source into Okta and can 93 | be mapped from Okta to a target. 94 | 95 | Below are the main operations in Okta's SCIM user provisioning lifecycle: 96 | 97 | 1. Create a user account. 98 | 2. Read a list of accounts, with support for searching for a preexisting account. 99 | 3. Update an account (user profile changes, entitlement changes, etc). 100 | 4. Deactivate an account. 101 | 102 | In Okta, an application instance is a connector that provides Single Sign-On 103 | and provisioning functionality with the target application. 104 | 105 | 106 | ## Required SCIM Capabilities 107 | 108 | Okta supports provisioning to both SCIM 1.1 and SCIM 2.0 APIs. 109 | 110 | If you haven't implemented SCIM, Okta recommends that you implement 111 | SCIM 2.0. 112 | 113 | Okta implements SCIM 2.0 as described in RFCs [7642](https://tools.ietf.org/html/rfc7642), [7643](https://tools.ietf.org/html/rfc7643), [7644](https://tools.ietf.org/html/rfc7644). 114 | 115 | If you are writing a SCIM implementation for the first time, an 116 | important part of the planning process is determining which of 117 | Okta's provisioning features your SCIM API can or should support and 118 | which features you do not need to support. 119 | 120 | Specifically, you do not need to implement the SCIM 2.0 121 | specification fully to work with Okta. At a minimum, Okta requires that 122 | your SCIM 2.0 API implement the features described below: 123 | 124 | ### Base URL 125 | 126 | The API endpoint for your SCIM API **MUST** be secured via [TLS](https://tools.ietf.org/html/rfc5246) 127 | (`https://`), Okta *does not* connect to unsecured API endpoints. 128 | 129 | You can choose any Base URL for your API endpoint. If you 130 | are implementing a brand new SCIM API, we suggest using `/scim/v2` 131 | as your Base URL; for example: `https://example.com/scim/v2` - 132 | however, you must support the URL structure described in the 133 | ["SCIM Endpoints and HTTP Methods" section of RFC7644](https://tools.ietf.org/html/rfc7644#section-3.2). 134 | 135 | ### Authentication 136 | 137 | Your SCIM API **MUST** be secured against anonymous access. At the 138 | moment, Okta supports authentication against SCIM APIs with one of 139 | the following methods: 140 | 141 | 1. [OAuth 2.0](http://oauth.net/2/) 142 | 2. [Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) 143 | 3. Custom HTTP Header 144 | 145 | ### Basic User Schema 146 | 147 | Your service must be capable of storing the following four user 148 | attributes: 149 | 150 | 1. User ID (`userName`) 151 | 2. First Name (`name.givenName`) 152 | 3. Last Name (`name.familyName`) 153 | 4. Email (`emails`) 154 | 155 | Note that Okta supports more than the four user attributes listed 156 | above. However, these four attributes are the base attributes that 157 | you must support. The full user schema for SCIM 2.0 is described 158 | in [section 4 of RFC 7643](https://tools.ietf.org/html/rfc7643#section-4). 159 | 160 | > **Best Practice:** Keep your User ID distinct from the User Email 161 | > Address. Many systems use an email address as a user identifier, 162 | > but this is not recommended, as email addresses often change. Using 163 | > a unique User ID to identify user resources prevents future 164 | > complications. 165 | 166 | If your service supports user attributes beyond those four base 167 | attributes, add support for those additional 168 | attributes to your SCIM API. In some cases, you might need to 169 | configure Okta to map non-standard user attributes into the user 170 | profile for your application. 171 | 172 | 173 | # License information 174 | 175 | Copyright © 2016, Okta, Inc. 176 | 177 | Permission is hereby granted, free of charge, to any person obtaining 178 | a copy of this software and associated documentation files (the 179 | "Software"), to deal in the Software without restriction, including 180 | without limitation the rights to use, copy, modify, merge, publish, 181 | distribute, sublicense, and/or sell copies of the Software, and to 182 | permit persons to whom the Software is furnished to do so, subject to 183 | the following conditions: 184 | 185 | The above copyright notice and this permission notice shall be 186 | included in all copies or substantial portions of the Software. 187 | 188 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 189 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 190 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 191 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 192 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 193 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 194 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /scim-server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright © 2016, Okta, Inc. 5 | 6 | import flask 7 | from flask import Flask 8 | from flask import render_template 9 | from flask import request 10 | from flask import url_for 11 | import re 12 | import json 13 | import boto3 14 | 15 | 16 | app = Flask(__name__) 17 | 18 | config = None 19 | with open('.config.json') as config_file: 20 | config_json = json.load(config_file) 21 | config = config_json['config'] 22 | 23 | USER_POOL_ID = config['aws']['user_pool_id'] 24 | ACCESS_KEY = config['aws']['access_key'] 25 | SECRET_KEY = config['aws']['secret_key'] 26 | 27 | cognito_client = boto3.client( 28 | 'cognito-idp', 29 | aws_access_key_id=ACCESS_KEY, 30 | aws_secret_access_key=SECRET_KEY 31 | ) 32 | 33 | 34 | class ListResponse(): 35 | def __init__(self, list, start_index=1, count=None, total_results=0): 36 | self.list = list 37 | self.start_index = start_index 38 | self.count = count 39 | self.total_results = total_results 40 | 41 | def to_scim_resource(self): 42 | rv = { 43 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 44 | "totalResults": self.total_results, 45 | "startIndex": self.start_index, 46 | "Resources": [] 47 | } 48 | resources = [] 49 | for item in self.list: 50 | user = CognitoUser(item) 51 | resources.append(user.to_scim_resource()) 52 | if self.count: 53 | rv['itemsPerPage'] = self.count 54 | rv['Resources'] = resources 55 | return rv 56 | 57 | 58 | # Conforms the cognito user object to a SCIM format 59 | class CognitoUser: 60 | def __init__(self, resource): 61 | self.update(resource) 62 | 63 | def update(self, resource): 64 | setattr(self, 'userName', resource['Username']) 65 | setattr(self, 'active', resource['Enabled']) 66 | 67 | displayName = resource['Username'] 68 | if 'Attributes' in resource: 69 | for pair in resource['Attributes']: 70 | if pair['Name'] == 'name': 71 | setattr(self, 'displayName', pair['Value']) 72 | if 'UserAttributes' in resource: 73 | for pair in resource['UserAttributes']: 74 | if pair['Name'] == 'name': 75 | setattr(self, 'displayName', pair['Value']) 76 | setattr(self, 'displayName', displayName) 77 | 78 | def to_scim_resource(self): 79 | rv = { 80 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], 81 | "id": self.userName, 82 | "userName": self.userName, 83 | "name": { 84 | "displayName": self.displayName 85 | }, 86 | "active": self.active, 87 | "meta": { 88 | "resourceType": "User", 89 | "location": url_for('user_get', 90 | user_id=self.userName, 91 | _external=True), 92 | # "created": "2010-01-23T04:56:22Z", 93 | # "lastModified": "2011-05-13T04:42:34Z", 94 | } 95 | } 96 | return rv 97 | 98 | 99 | def scim_error(message, status_code=500): 100 | rv = { 101 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], 102 | "detail": message, 103 | "status": str(status_code) 104 | } 105 | return flask.jsonify(rv), status_code 106 | 107 | 108 | def render_json(obj): 109 | user = CognitoUser(obj) 110 | rv = user.to_scim_resource() 111 | return flask.jsonify(rv) 112 | 113 | 114 | @app.route('/') 115 | def hello(): 116 | return render_template('base.html') 117 | 118 | 119 | @app.route("/scim/v2/Users/", methods=['GET']) 120 | def user_get(user_id): 121 | try: 122 | response = cognito_client.admin_get_user( 123 | UserPoolId=USER_POOL_ID, 124 | Username=user_id 125 | ) 126 | except: 127 | return scim_error("User not found", 404) 128 | return render_json(response) 129 | 130 | 131 | @app.route("/scim/v2/Users", methods=['POST']) 132 | def users_post(): 133 | user_resource = request.get_json(force=True) 134 | 135 | username = user_resource['userName'] 136 | name = user_resource['name']['givenName'] + ' ' + user_resource['name']['familyName'] 137 | 138 | response = cognito_client.admin_create_user( 139 | UserPoolId=USER_POOL_ID, 140 | Username=username, 141 | UserAttributes=[ 142 | {'Name': 'name', 'Value': name}, 143 | {'Name': 'email', 'Value': username} 144 | ], 145 | TemporaryPassword='123456', 146 | ForceAliasCreation=False, 147 | MessageAction='SUPPRESS', 148 | DesiredDeliveryMediums=['EMAIL'] 149 | ) 150 | user = CognitoUser(response['User']) 151 | rv = user.to_scim_resource() 152 | resp = flask.jsonify(rv) 153 | resp.headers['Location'] = url_for('user_get', user_id=username, _external=True) 154 | return resp, 201 155 | 156 | 157 | @app.route("/scim/v2/Users/", methods=['PUT']) 158 | def users_put(user_id): 159 | user_resource = request.get_json(force=True) 160 | # TODO: Implement update user attributes 161 | 162 | try: 163 | user = cognito_client.admin_get_user( 164 | UserPoolId=USER_POOL_ID, 165 | Username=user_id 166 | ) 167 | except: 168 | return scim_error("User not found", 404) 169 | 170 | return render_json(user) 171 | 172 | 173 | @app.route("/scim/v2/Users/", methods=['PATCH']) 174 | def users_patch(user_id): 175 | patch_resource = request.get_json(force=True) 176 | for attribute in ['schemas', 'Operations']: 177 | if attribute not in patch_resource: 178 | message = "Payload must contain '{}' attribute.".format(attribute) 179 | return message, 400 180 | schema_patchop = 'urn:ietf:params:scim:api:messages:2.0:PatchOp' 181 | if schema_patchop not in patch_resource['schemas']: 182 | return "The 'schemas' type in this request is not supported.", 501 183 | 184 | deactivate = None 185 | reactivate = None 186 | for operation in patch_resource['Operations']: 187 | if 'op' not in operation and operation['op'] != 'replace': 188 | continue 189 | value = operation['value'] 190 | for key in value.keys(): 191 | if key == 'active': 192 | val = str(value[key]) 193 | if val == ''.join('False'): 194 | deactivate = True 195 | else: 196 | reactivate = True 197 | if deactivate: 198 | try: 199 | response = cognito_client.admin_disable_user( 200 | UserPoolId=USER_POOL_ID, 201 | Username=user_id 202 | ) 203 | except: 204 | return scim_error("User not found", 404) 205 | if reactivate: 206 | try: 207 | response = cognito_client.admin_enable_user( 208 | UserPoolId=USER_POOL_ID, 209 | Username=user_id 210 | ) 211 | except: 212 | return scim_error("User not found", 404) 213 | 214 | try: 215 | user = cognito_client.admin_get_user( 216 | UserPoolId=USER_POOL_ID, 217 | Username=user_id 218 | ) 219 | except: 220 | return scim_error("User not found", 404) 221 | return render_json(user) 222 | 223 | 224 | @app.route("/scim/v2/Users", methods=['GET']) 225 | def users_get(): 226 | count = int(request.args.get('count', 100)) 227 | start_index = int(request.args.get('startIndex', 1)) 228 | if start_index < 1: 229 | start_index = 1 230 | start_index -= 1 231 | 232 | match = None 233 | filter = None 234 | search_key_name = None 235 | request_filter = request.args.get('filter') 236 | # Handling the filter users requirement... 237 | # see more info at: https://github.com/oktadeveloper/okta-scim-beta#filtering-on-id-username-and-emails 238 | if request_filter: 239 | match = re.match('(\w+) eq "([^"]*)"', request_filter) 240 | if match: 241 | (search_key_name, search_value) = match.groups() 242 | if search_key_name == 'userName': 243 | search_key_name = 'username' 244 | elif search_key_name == 'emails': 245 | search_key_name = 'email' 246 | elif search_key_name == 'id': 247 | search_key_name = 'username' 248 | if search_key_name: 249 | filter = search_key_name + ' = ' + '"' + search_value + '"' 250 | 251 | if filter: 252 | response = cognito_client.list_users( 253 | UserPoolId=USER_POOL_ID, 254 | AttributesToGet=[ 255 | 'name', 'email' 256 | ], 257 | Limit=60, 258 | Filter=filter 259 | ) 260 | else: 261 | response = cognito_client.list_users( 262 | UserPoolId=USER_POOL_ID, 263 | AttributesToGet=[ 264 | 'name', 'email' 265 | ], 266 | Limit=60 267 | ) 268 | 269 | found = response['Users'] 270 | total_results = len(found) 271 | rv = ListResponse(found, 272 | start_index=start_index, 273 | count=count, 274 | total_results=total_results) 275 | return flask.jsonify(rv.to_scim_resource()) 276 | 277 | 278 | @app.route("/scim/v2/Groups", methods=['GET']) 279 | def groups_get(): 280 | # TODO: implement GET Groups 281 | rv = ListResponse([]) 282 | return flask.jsonify(rv.to_scim_resource()) 283 | 284 | 285 | if __name__ == "__main__": 286 | app.run() --------------------------------------------------------------------------------