├── .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()
--------------------------------------------------------------------------------