Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ LanguageTool-5.4
package-lock.json
learning_observer/learning_observer/static_data/google/
learning_observer/learning_observer/static_data/admins.yaml
.ipynb_checkpoints/
.ipynb_checkpoints/

107 changes: 107 additions & 0 deletions docs/lms_integrations/canvas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
## Canvas LMS Documentation:
Reference: https://canvas.instructure.com/doc/api/file.oauth.html

This guide will walk you through the process of obtaining the `client_id`, `client_secret`, and `refresh_token` for interacting with the Canvas LMS API. These credentials are essential for making authenticated API requests to Canvas.

### Prerequisites

- You need to have administrator access to the Canvas LMS instance.

### Steps to Obtain the `client_id` and `client_secret`

1. **Log in to Your Canvas LMS Account**:
- Go to your Canvas LMS instance and log in with your administrator credentials.

2. **Navigate to the Developer Keys Section**:
- From the Canvas dashboard, click on the **Admin** panel located on the left-hand side.
- Select the specific account (usually your institution's name) where you want to manage developer keys.
- Scroll down and click on **Developer Keys** in the left-hand menu under the **Settings** section.

3. **Create a New Developer Key**:
- In the Developer Keys section, click the **+ Developer Key** button at the top-right corner.
- Choose **API Key** from the dropdown menu.

4. **Fill Out the Developer Key Details**:
- **Name**: Enter a name for the Developer Key (e.g., "My Canvas API Integration").
- **Owner's Email**: Enter administrator's email.
- **Redirect URIs**: Provide the redirect URI that will handle OAuth callbacks. This is typically a URL on your institution server where you handle OAuth responses.

5. **Save and Enable the Developer Key**:
- After filling out the required information, click **Save Key**.
- Ensure the key is **enabled** by toggling the switch next to your newly created key.

6. **Obtain the `client_id` and `client_secret`**:
- After saving, your `client_id` and `client_secret` will be displayed in the list of developer keys.
- **Client ID**: This is usually displayed as a numeric value in the details column.
- **Client Secret**: Click on the `show key` button and it will display the `client_secret`.

### Steps to Obtain the `refresh_token`

1. **Redirect User to Canvas Authorization Endpoint**:
- To obtain the `refresh_token`, you need to perform an OAuth flow.
- Direct the user to the Canvas OAuth authorization endpoint:
```
https://canvas.instructure.com/login/oauth2/auth?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REDIRECT_URI
```
- Replace `YOUR_CLIENT_ID` with the `client_id` obtained earlier and `YOUR_REDIRECT_URI` with the redirect URI you configured.

2. **User Authorizes the Application**:
- The user will be prompted to log in (if not already logged in) and authorize the application to access their Canvas data.

3. **Handle the Authorization Code**:
- After the user authorizes the application, they will be redirected to the `redirect_uri` you provided, with an authorization `code` appended as a query parameter.
- Example: `https://your-redirect-uri.com?code=AUTHORIZATION_CODE`

4. **Exchange the Authorization Code for a Refresh Token**:
- Use the authorization `code` to request an access token and refresh token by making a POST request to the Canvas token endpoint:
```
POST https://canvas.instructure.com/login/oauth2/token
```
- Include the following parameters in the request body:
- `client_id`: Your Canvas `client_id`
- `client_secret`: Your Canvas `client_secret`
- `redirect_uri`: Your `redirect_uri` used in the authorization request
- `code`: The authorization code you received
- `grant_type`: Set this to `authorization_code`

- Example of the POST request in `curl`:
```bash
curl -X POST https://canvas.instructure.com/login/oauth2/token \
-F 'client_id=YOUR_CLIENT_ID' \
-F 'client_secret=YOUR_CLIENT_SECRET' \
-F 'redirect_uri=YOUR_REDIRECT_URI' \
-F 'code=AUTHORIZATION_CODE' \
-F 'grant_type=authorization_code'
```

5. **Extract the Refresh Token**:
- The response to the token request will include an `access_token`, a `refresh_token`, and other token information.
- **Refresh Token**: This token can be used to obtain new access tokens without requiring the user to re-authorize.

### Example JSON Response from Token Request

```json
{
"access_token": "ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "REFRESH_TOKEN",
"user": {
"id": 12345,
"name": "John Doe",
"sortable_name": "Doe, John",
"short_name": "John"
}
}
```

- **`refresh_token`**: The value you will need to store securely for future use.

### Important Notes

- **Security**: The `client_id`, `client_secret`, and `refresh_token` should be stored securely. Do not expose them in client-side code or public repositories.
- **Token Expiration**: The `access_token` typically expires after a short period (e.g., 1 hour). The `refresh_token` does not expire as quickly and can be used to obtain new `access_token`s.

### Conclusion

By following these steps, you will obtain the necessary credentials (`client_id`, `client_secret`, and `refresh_token`) to interact with the Canvas LMS API programmatically. These credentials are essential for making authenticated requests to access and manage Canvas resources through the API.
7 changes: 5 additions & 2 deletions learning_observer/learning_observer/auth/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ async def user_from_session(request):
'''
session = await aiohttp_session.get_session(request)
session_user = session.get(constants.USER, None)
if constants.AUTH_HEADERS in session:
request[constants.AUTH_HEADERS] = session[constants.AUTH_HEADERS]
header_keys = [constants.AUTH_HEADERS, constants.CANVAS_AUTH_HEADERS]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code like this won't scale easily to more LMSs. We ought to have a better auth system that can handle multiple auths at once.
Example: a teacher might need their roster from Canvas, but also needs to sign into Google to get updated document text.

# Set headers in the request if they exist in the session
for key in header_keys:
if key in session:
request[key] = session[key]
return session_user


Expand Down
72 changes: 66 additions & 6 deletions learning_observer/learning_observer/auth/social_sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,33 @@
pmss.register_field(
name="client_id",
type=pmss.pmsstypes.TYPES.string,
description="The Google OAuth client ID",
description="The Google/Canvas OAuth client ID",
required=True
)
pmss.register_field(
name="client_secret",
type=pmss.pmsstypes.TYPES.string,
description="The Google OAuth client secret",
description="The Google/Canvas OAuth client secret",
required=True
)
pmss.register_field(
name='fetch_additional_info_from_teacher_on_login',
type=pmss.pmsstypes.TYPES.boolean,
description='Whether we should start an additional task that will '\
'fetch all text from current rosters.',
description='Whether we should start an additional task that will fetch all text from current rosters.',
default=False
)
pmss.register_field(
name="token_uri",
type=pmss.pmsstypes.TYPES.string,
description="The Canvas OAuth token uri",
required=True
)
pmss.register_field(
name="refresh_token",
type=pmss.pmsstypes.TYPES.string,
description="The Canvas OAuth refresh token",
required=True
)


DEFAULT_GOOGLE_SCOPES = [
Expand Down Expand Up @@ -128,7 +139,11 @@ async def social_handler(request):
"We only handle Google logins. Non-google Provider"
)

user = await _google(request)
user = await _handle_google_authorization(request)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function ought to handle a single option of signing in for a given API.

When we create the endpoints for Oauth, we ought to initialized them separately.

For example in Routes, where we initialize the google oauth route, we should use

    # Copied from learning_observer/routes.py:239
    if 'google_oauth' in settings.settings['auth']:
        debug_log("Running with Google authentication")
        app.add_routes([
            aiohttp.web.get(
                '/auth/login/{provider:google}',
                # the below call to `social_handler` ought to pass a parameter "google"
                handler=learning_observer.auth.social_handler),
        ])

For the canvas oauth routes, we should add a similar route but with
handler=learning_observer.auth.social_handler('canvas') or similar.

The passed method should call the appropriate authorization function instead of always calling Google and sometimes calling Canvas/other LMSs


roster_source = settings.pmss_settings.source(types=['roster_data'])

await _set_lms_header_information(request, roster_source)

if constants.USER_ID in user:
await learning_observer.auth.utils.update_session_user_info(request, user)
Expand All @@ -143,6 +158,21 @@ async def social_handler(request):
return aiohttp.web.HTTPFound(url)


async def _set_lms_header_information(request, roster_source):
"""
Handles the authorization of the specified Learning Management System (LMS)
based on the roster data source and delegating the request to the appropriate handler
based on the data source type.
"""
lms_map = {
constants.CANVAS: _handle_canvas_authorization
}

# Handle the request depending on the roster source
if roster_source in lms_map:
return await lms_map[roster_source](request)


async def _store_teacher_info_for_background_process(id, request):
'''HACK this code stores 2 pieces of information when
teacher logs in with a social handlers.
Expand Down Expand Up @@ -211,7 +241,37 @@ async def _process_student_documents(student):
# TODO saved skipped doc ids somewhere?


async def _google(request):
async def _handle_canvas_authorization(request):
'''
Handle Canvas authorization
'''
if 'error' in request.query:
return {}

token_uri = settings.pmss_settings.token_uri(types=['lms', 'canvas_oauth'])
url = token_uri

params = {
"grant_type": "refresh_token",
'client_id': settings.pmss_settings.client_id(types=['lms', 'canvas_oauth']),
'client_secret': settings.pmss_settings.client_secret(types=['lms', 'canvas_oauth']),
"refresh_token": settings.pmss_settings.refresh_token(types=['lms', 'canvas_oauth'])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my understanding of the Oauth workflow, the refresh_token should be tied to the user signing in and not generically tied to the system. This token is used to fetch new access_tokens when needed without requiring another sign-on.

The Google API code in LO did not implement the refresh_token functionality, though it should do something with it.

}
async with aiohttp.ClientSession(loop=request.app.loop) as client:
async with client.post(url, data=params) as resp:
data = await resp.json()
assert 'access_token' in data, data

# get user profile
canvas_headers = {'Authorization': 'Bearer ' + data['access_token']}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We ought to be storing more than just the access_token for each user. We should include the refresh_token if available and some additional metadata about when the sign-in occurred.

session = await aiohttp_session.get_session(request)
session[constants.CANVAS_AUTH_HEADERS] = canvas_headers
request[constants.CANVAS_AUTH_HEADERS] = canvas_headers

return data


async def _handle_google_authorization(request):
'''
Handle Google login
'''
Expand Down
67 changes: 67 additions & 0 deletions learning_observer/learning_observer/canvas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import functools

import learning_observer.auth
import learning_observer.lms_integration
import learning_observer.constants as constants


LMS_NAME = constants.CANVAS

CANVAS_ENDPOINTS = list(map(lambda x: learning_observer.lms_integration.Endpoint(*x, "", None, LMS_NAME), [
("course_list", "/courses"),
("course_roster", "/courses/{courseId}/students"),
("course_assignments", "/courses/{courseId}/assignments"),
("course_assignments_submissions", "/courses/{courseId}/assignments/{assignmentId}/submissions"),
]))

register_cleaner_with_endpoints = functools.partial(learning_observer.lms_integration.register_cleaner, endpoints=CANVAS_ENDPOINTS)


class CanvasLMS(learning_observer.lms_integration.LMS):
def __init__(self):
super().__init__(lms_name=LMS_NAME, endpoints=CANVAS_ENDPOINTS)

@register_cleaner_with_endpoints("course_roster", "roster")
def clean_course_roster(canvas_json):
students = canvas_json
students_updated = []
for student_json in students:
canvas_id = student_json['id']
integration_id = student_json['integration_id']
local_id = learning_observer.auth.google_id_to_user_id(integration_id)
student = {
"course_id": "1",
"user_id": local_id,
"profile": {
"id": canvas_id,
"name": {
"given_name": student_json['name'],
"family_name": student_json['name'],
"full_name": student_json['name']
}
}
}
if 'external_ids' not in student_json:
student_json['external_ids'] = []
student_json['external_ids'].append({"source": constants.CANVAS, "id": integration_id})
students_updated.append(student)
return students_updated

@register_cleaner_with_endpoints("course_list", "courses")
def clean_course_list(canvas_json):
courses = canvas_json
courses.sort(key=lambda x: x.get('name', 'ZZ'))
return courses

@register_cleaner_with_endpoints("course_assignments", "assignments")
def clean_course_assignment_list(canvas_json):
assignments = canvas_json
assignments.sort(key=lambda x: x.get('name', 'ZZ'))
return assignments


canvas_lms = CanvasLMS()


def initialize_canvas_routes(app):
canvas_lms.initialize_routes(app)
5 changes: 5 additions & 0 deletions learning_observer/learning_observer/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@
'''
# used in request headers to hold auth information
AUTH_HEADERS = 'auth_headers'
CANVAS_AUTH_HEADERS = 'canvas_auth_headers'
# used for storing impersonation information in session
IMPERSONATING_AS = 'impersonating_as'

# used for fetching user object from request or session
USER = 'user'
# common user id reference for user object
USER_ID = 'user_id'

# used to identify LMSes
GOOGLE = 'google'
CANVAS = 'canvas'
7 changes: 7 additions & 0 deletions learning_observer/learning_observer/creds.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,10 @@ modules:
writing_observer:
use_nlp: false
openai_api_key: '' # can also be set with OPENAI_API_KEY environment variable
lms:
canvas_oauth:
lms_api: {canvas-lms-api}
token_uri: {canvas-token-uri}
client_id: {canvas-client-id}
client_secret: {canvas-client-secret}
refresh_token: {canvas-refresh-token}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradley-erickson When is the time to move this to PSS?

Loading
Loading