1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287#!/usr/bin/env python3 import json import webbrowser import secrets from os import environ from urllib.request import Request, urlopen from urllib.parse import urlencode, urlparse, parse_qs from pprint import pprint def openid_discovery(): """Perform OpenID Discovery SciStarter supports OpenID Connect Discovery, so there is a standardized, machine readable description of our OpendID/OAuth configuration which can be retrieved at any time. Normally, there is no need to perform discovery very often. Just cache the results. """ r = urlopen("https://scistarter.org/.well-known/openid-configuration") if r.status != 200: raise Exception(r.status, r.reason) return json.loads(r.read()) def authorize(config): """Perform the OAuth flow to retrieve an access token Authorization in OpenID Connect is via OAuth 2. """ # If we were writing a server-side request handler, it would be a # good idea to save the URL for the next page the user expects to # see at this time. Usually, we would store it in the session. # Then, once the authorization was complete, it would be easy to # forward user to where they expected to go. # This is a nonce which has no purpose or meaning except to make # it impossible for a hostile middleman to replay the request. The # state, too, should be saved in the session so as to accessible # later in the process. state = secrets.token_hex(16) # Normally we would be doing this in a server-side HTTP request # handler rather than in a standalone program, in which case we # would simply return a redirect response rather than messing # about with the webbrowser module. webbrowser.open_new_tab( config["authorization_endpoint"] + "?" + urlencode( { # The client_id is provided by SciStarter, email us at # info@scistarter.org to get one. "client_id": environ["SCISTARTER_CLIENT_ID"], "response_type": "code", # Requesting the openid scope is a protocol requirement # Requesting the profile scope gives us read access to # basic user information, if the user grants it. # Requesting the participation scope gives us the # right to add participation events to the user's # record, if the user grants it. "scope": "openid profile participation", "state": state, "redirect_uri": # This URL would never be the one you really use. # Instead, you will have provided one or more URLs on # your own domain to us when you requested a # client_id. The redirect_uri parameter must be one of # those URLs. "https://example.com/openid/scistarter", } ) ) # Once the user has granted permissions, scistarter will redirect # the user to the specified redirect_uri. That would result in # another request handler on your server processing that request to # complete the authorization. Since we're not in a server here, we # do this instead: redirected_to = input( "Please perform the authorization in your browser, then copy and paste the URL you get redirected to here: " ) params = parse_qs(urlparse(redirected_to).query) try: code = params["code"][0] except (KeyError, IndexError): raise Exception("No authorization code was provided") # Now that the user has identified themself to SciStarter and # granted permissions, we have to identify ourself as the rightful # recipient of those permissions and retrieve an access token. req = Request( method="POST", url=config["token_endpoint"], data=urlencode( { "client_id": environ["SCISTARTER_CLIENT_ID"], "client_secret": environ["SCISTARTER_CLIENT_SECRET"], # redirect_uri must match the redirect_uri used above "redirect_uri": "https://example.com/openid/scistarter", "grant_type": "authorization_code", "code": code, "state": state, } ).encode("utf8"), ) r = urlopen(req) if r.status != 200: raise Exception(r.status, r.reason) authorization = json.loads(r.read()) # Now we have an access token, which will be good for several # hours and which will allow us to access SciStarter on the user's # behalf. access_token = authorization["access_token"] # We also have a refresh token, which can be used to retrieve a # new access token after the current one expires, as long as the # user does not have it revoked. refresh_token = authorization["refresh_token"] return access_token, refresh_token def refresh(config, refresh_token): """Retrieve a new access token, given a refresh token Access tokens expire after a time. Refresh tokens don't expire until they are used or revoked. For use cases where you're not using OpenID Connect as your primary way of authenticating the user, it's quite possible for the user to come back to your site and authenticate through other means long after the access token is no longer valid. When this happens, you can use the refresh token to renew your access to SciStarter, without interrupting the user to ask for permissions that you have already been granted. Refresh tokens should never leave your server, except to be sent to ours over an encrypted connection. SciStarter's refresh tokens are single use. Using a refresh token consumes it, and issues a new one along with an access token. """ req = Request( method="POST", url=config["token_endpoint"], data=urlencode( { "client_id": environ["SCISTARTER_CLIENT_ID"], "client_secret": environ["SCISTARTER_CLIENT_SECRET"], "grant_type": "refresh_token", "refresh_token": refresh_token, } ).encode("utf8"), ) r = urlopen(req) if r.status != 200: raise Exception(r.status, r.reason) authorization = json.loads(r.read()) return authorization['access_token'], authorization['refresh_token'] def get_profile(config, token): """Retrieve user information In the process of granting the token, the user was prompted to allow (or deny) us access to their information. The specific information depends on the scopes we requested, but whatever information they allowed is available via the userinfo endpoint. If participation API access was granted, the 'participation_api_granted' field in the return value will be true. It's not necessary to retrieve the profile information every time you are authorized. Once is usually sufficient. """ req = Request( method="GET", url=config["userinfo_endpoint"], # Any request that is authorized by the token should have this # Authorization HTTP header headers={"Authorization": "Bearer " + token}, ) r = urlopen(req) if r.status != 200: raise Exception(r.status, r.reason) return json.loads(r.read()) def record_participation(config, token, project_slug=None): """Record a participation event for the user This will only work if the participation scope is available, which means that the user specifically granted permission to share information with SciStarter. There are two variations on this process: if we only have one project associated with the client_id, all we need is our token. On the other hand, if have more than one project for the same client_id, we also need to provide the slug which identifies the specific project that the user participated in. The project_slug parameter should contain the textual unique identifier of the project. It is easily accesible from the project URL. In the URL https://scistarter.org/airborne-walrus-capture the slug is the string airborne-walrus-capture """ if project_slug is None: endpoint = "https://scistarter.org/api/participation/openid" else: endpoint = "https://scistarter.org/api/participation/openid/" + project_slug req = Request( method="POST", url=endpoint, headers={"Authorization": "Bearer " + token}, data=urlencode( { "type": "classification", # other options: 'collection', 'signup' "duration": 31, # Seconds the user spent participating, or an estimate } ).encode("utf8"), ) r = urlopen(req) if r.status != 200: raise Exception(r.status, r.reason) return json.loads(r.read()) if __name__ == "__main__": config = openid_discovery() print("CONFIG") pprint(config) access_token, refresh_token = authorize(config) print("REFRESH_TOKEN") print(refresh_token) # We don't need to do this at this time, except to demonstrate # how. The current access token is valid, since we just retrieved # it, and does not need to be refreshed. access_token, refresh_token = refresh(config, refresh_token) print("ACCESS TOKEN") pprint(access_token) profile = get_profile(config, access_token) print("PROFILE") pprint(profile) result = record_participation(config, access_token) print("RESULT") pprint(result)