API for ACCESS CI accounts and registration.
- Python 3.13 or higher
- uv package manager
- Clone the repository:
git clone git@github.com:access-ci-org/ACCESS_Account_Backend.git
cd ACCESS_Account_Backend- Install dependencies using uv:
uv syncThe API requires several environment variables to be set. Create a .env file in the project root:
# Required: JWT secret key for signing tokens
# Generate a secure random string (e.g., using: openssl rand -hex 32)
JWT_SECRET_KEY=your-secure-secret-key-here
# Optional: JWT configuration (defaults shown)
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
JWT_ISSUER=https://account.access-ci.org
JWT_AUDIENCE=https://account.access-ci.org
# Optional: Application configuration
CORS_ORIGINS=http://localhost:3000,https://access-ci-org.github.io
DEBUG=false
FRONTEND_URL=http://localhost:3000
# Optional: comma-separated ACCESS usernames granted administrative access
# (e.g., to read or update any user's profile)
ADMIN_USERNAMES=
# Optional: backing store for rate limiting (defaults to in-memory, which is
# not shared across multiple worker processes)
RATE_LIMIT_STORAGE_URL=memory://
# Optional: SES delivery status logging (see POST /email/callback). Both must be
# set for SES events to reach the callback.
AWS_SES_CONFIGURATION_SET=
AWS_SNS_TOPIC_ARN=Important: The JWT_SECRET_KEY is required and must be set. The application will fail to start without it.
Start the development server:
uv run python main.pyThe API will be available at http://localhost:8000.
Once the server is running, you can access the interactive API documentation:
- Swagger UI: http://localhost:8000/api/docs
- ReDoc: http://localhost:8000/api/redoc
- OpenAPI JSON: http://localhost:8000/openapi.json
All API routes are prefixed with /api/v1.
Users can authenticate to the Account API using two methods, which provide different levels of privilege:
- By providing a one-time password (OTP) sent to their email address. This type of authentication proves their ownership of the email address but does not assert ownership of an ACCESS account (or even that an associated ACCESS account exists).
- By completing the CILogon OAuth flow. This type of authentication proves the user's identity and their ownership of the associated ACCESS account, if any.
The /auth/* routes described below return a JSON Web Token (JWT) with the following claims (in addition to the standard claims like iss, exp, etc.):
sub: The e-mail address.typ: The authentication type (one ofotporlogin).uid: The ACCESS username, if there is one associated with the email address.
The other routes authenticate using one or both of these JWT types via the Authorization header.
Make parallel connectivity checks against CoManage Registry, CILogon, the Identity Service, and AWS SES, and report whether each is reachable. Always returns HTTP 200; inspect the response body for per-service status. Does not require authentication.
{
"comanageRegistry": {"reachable": true, "statusCode": 200, "detail": null},
"cilogon": {"reachable": true, "statusCode": 200, "detail": null},
"identityService": {"reachable": true, "statusCode": 200, "detail": null},
"awsSes": {"reachable": true, "statusCode": 200, "detail": null}
}Send a one-time password (OTP) to the specified email, if it exists. In order to avoid revealing whether the email has an associated account, we should send the OTP regardless of whether the domain is allowed by ACCESS. Prohibited domains will be flagged after the user enters the OTP.
This route is rate-limited to 1000 requests/day per IP address and 4 requests/hour per email address.
{
"email": "user@example.edu"
}The OTP was sent.
The OTP could not be sent (e.g., due to a malformed email address).
The rate limit was exceeded (by IP address or by email address).
Verify an OTP provided by the user.
{
"email": "user@example.edu",
"otp": "abc123"
}The OTP is valid. Return a JWT of type otp.
{
"jwt": "<jwt>"
}The request body is malformed.
The OTP is invalid.
Get the CILogon OIDC client IDs and authorization URL.
Return the OIDC client IDs and authorization URL.
{
"authorization_url": "<authorization_url>",
"client_ids": {
"login": "<login_client_id>",
"link": "<link_client_id>"
}
}Exchange an authorization code for OIDC tokens, or refresh existing tokens using a refresh token.
{
"client_id": "<cilogon_client_id>",
"grant_type": "authorization_code",
"redirect_uri": "https://example.com/callback",
"code": "<authorization_code>"
}For token refresh, use grant_type: "refresh_token" and provide refresh_token instead of code. redirect_uri is still required by the request schema even when refreshing.
Return the OIDC tokens. isAdmin is only populated (and only meaningful) for the login client, based on whether the CILogon username is in ADMIN_USERNAMES.
{
"accessToken": "<access_token>",
"idToken": "<id_token>",
"refreshToken": "<refresh_token>",
"isAdmin": false
}Request a password reset for an unauthenticated user.
Authorization: containing a JWT of typeotp.
{
"password": "New Password"
}The password was updated.
{
"success": true
}The password does not conform to the ACCESS password policy
The JWT is invalid
No account was found for the provided email address
Create a new account. The email address is taken from the sub claim of the OTP JWT, not from the request body.
Authorization: containing a JWT of typeotp.
cilogonToken is optional; if provided (a CILogon access token from another IdP obtained outside this OTP flow), a second linked identity is created for that IdP in addition to the ACCESS IdP identity.
{
"firstName": "Jane",
"lastName": "Doe",
"organizationId": 123,
"academicStatusId": 101,
"residenceCountryId": 201,
"citizenshipCountryIds": [201],
"department": "Computer Science",
"cilogonToken": ""
}The account was created. Returns the newly created ACCESS ID.
{
"success": true,
"access_id": "jdoe"
}The input failed validation (e.g., the organization does not match the e-mail domain, the academic status ID is invalid, or an account for that email address already exists). Return an error message indicating the problem.
{
"detail": "Organization does not match email domain."
}The JWT is invalid or is not of type otp.
Get the profile for the given account. Data is merged from CoManage Registry (name, email addresses, time zone) and the Identity Service (organization, academic status, country/citizenship, degrees, department).
Authorization: containing a JWT of typelogin. The uid claim must match the requested username, or be an administrative user.
Return the profile information for the user.
{
"username": "jdoe",
"firstName": "Jane",
"lastName": "Doe",
"emails": [
{"email": "jdoe@example.edu", "primary": true},
{"email": "jdoe2@other.edu", "primary": false}
],
"timeZone": "America/New_York",
"organizationId": 123,
"academicStatusId": 101,
"residenceCountryId": 201,
"citizenshipCountryIds": [201],
"degrees": [
{"degreeId": 1, "degreeField": "Computer Science"}
],
"department": "Computer Science"
}The JWT is invalid or the user does not have permission to access the account.
The requested user does not exist, or does not have a primary name on record.
Update the profile information for an account. All fields are optional; omitted fields are left unchanged. Fields set to null/omitted are not modified, but fields that are provided always replace the existing value (there is no partial update of a list field like emails, citizenshipCountryIds, or degrees).
Authorization: containing a JWT of typelogin. The uid claim must match the requested username, or be an administrative user.
If an emails list is provided, it fully replaces the account's email addresses and exactly one entry must be marked primary. Any address in the list that is not already on the account's CoManage record must include a valid otpToken (a JWT of type otp whose sub matches that email address) proving ownership; this applies to new primary and new recovery addresses alike. The domain of the resulting primary email must match organizationId.
{
"firstName": "Jane",
"lastName": "Doe",
"emails": [
{"email": "jdoe@example.edu", "primary": true},
{"email": "jdoe2@other.edu", "primary": false, "otpToken": "<jwt_for_jdoe2>"}
],
"organizationId": 123,
"academicStatusId": 101,
"residenceCountryId": 201,
"citizenshipCountryIds": [201],
"degrees": [
{"degreeId": 1, "degreeField": "Computer Science"}
],
"timeZone": "America/New_York",
"department": "Computer Science"
}The request schema also accepts a programRole field, but the handler does not currently apply it to either backing store.
The account profile was updated.
The input failed validation (e.g., not exactly one primary email, a missing/invalid otpToken for a new email address, an invalid academic status ID, or the primary email's domain does not match the organization). Return a message describing the problem.
{
"detail": "Exactly one email address must be marked as primary."
}The JWT is invalid or the user does not have permission to update the account.
Set or update the password for the account in the ACCESS IDP.
Authorization: containing a JWT of typelogin. The uid claim must match the requested username.
{
"password": "my N3w very $ecure passw0rd!"
}The password was updated.
The password does not conform to the ACCESS password policy. Return a message describing the problem.
{
"detail": "The password does not conform to the ACCESS password policy."
}The JWT is invalid or the user does not have permission to update the password.
The account or password record was not found.
The password update failed in CoManage Registry.
Get a list of identities associated with this account. Each identity corresponds to an OrgIdentity record in CoManage (one per linked IdP, including the ACCESS IdP itself) and includes its raw Identifier records.
Authorization: containing a JWT of typelogin. The uid claim must match the requested username, or be an administrative user.
Return the list of linked identities.
{
"identities": [
{
"identityId": 15,
"organization": "Example University",
"identifiers": [
{"type": "eppn", "identifier": "jdoe15@example.edu", "login": true}
]
}
]
}The JWT is invalid or the user does not have permission to access the account.
The requested user does not exist.
Link a new identity to the account using a CILogon access token obtained from the link client (via POST /auth/oauth2/token with the link client ID).
Authorization: containing a JWT of typelogin. The uid claim must match the requested username.
{
"cilogonToken": "<cilogon_access_token>"
}The identity was linked.
{
"success": true
}The requested username does not exist.
The JWT is invalid or the user does not have permission to modify the account.
Delete a linked identity, including its Identifier records on both the OrgIdentity and the parent CoPerson, then unlink and delete the OrgIdentity itself.
Authorization: containing a JWT of typelogin. The uid claim must match the requested username.
The linked identity was deleted.
{
"success": true
}The specified identity cannot be deleted (the ACCESS IdP identity, i.e. an identifier of type eppn ending in @access-ci.org, can never be deleted) or the requested username does not exist. Return a message describing the problem.
{
"detail": "The ACCESS identity cannot be deleted"
}The JWT is invalid or the user does not have permission to modify the account.
The requested identity does not exist.
Get a list of SSH keys associated with this account.
Authorization: containing a JWT of typelogin. The uid claim must match the requested username, or be an administrative user.
Return the list of linked SSH keys.
{
"sshKeys": [
{
"keyId": 15,
"hash": "<ssh_key_hash>",
"created": "2025-07-01T10:00:00"
}
]
}The JWT is invalid or the user does not have permission to access the account.
The requested user does not exist.
Add a new SSH key to the account.
Authorization: containing a JWT of typelogin. The uid claim must match the requested username.
{
"publicKey": "<my_public_key>"
}The key was added successfully.
The provided key is not valid (e.g., empty) or is already associated with another account. Return a message describing the problem.
{
"detail": "The provided key is not valid."
}The JWT is invalid or the user does not have permission to modify the account.
Delete an SSH key.
Authorization: containing a JWT of typelogin. The uid claim must match the requested username.
The linked SSH key was deleted.
The JWT is invalid or the user does not have permission to modify the account.
The requested key does not exist.
Get a list of all possible academic statuses.
Authorization: containing a JWT of typeotporlogin.
Return a list of possible academic statuses.
{
"academicStatuses": [
{
"academicStatusId": 101,
"name": "Graduate Student"
}
]
}The JWT is invalid.
Get a list of all possible countries.
Authorization: containing a JWT of typeotporlogin.
Return a list of possible countries.
{
"countries": [
{
"countryId": 201,
"name": "United States"
}
]
}The JWT is invalid.
Get a list of all possible academic degrees.
Authorization: containing a JWT of typeotporlogin.
Return a list of possible academic degrees.
{
"degrees": [
{
"degreeId": 1,
"name": "Bachelor's"
}
]
}The JWT is invalid.
Get information about an email domain, including whether it meets ACCESS eligibility criteria, and associated organizations and IDPs, if any.
Authorization: containing a JWT of typeotporlogin.
Return lists of associated organizations and IdPs for the domain. organizations is the full XRAS organization record for each match; idps is omitted for organizations that set ignoreIdp.
{
"domain": "example.edu",
"organizations": [
{
"organizationId": 123,
"orgTypeId": 1,
"organizationAbbrev": "EXU",
"organizationName": "Example University",
"organizationUrl": "https://example.edu",
"organizationPhone": null,
"nsfOrgCode": null,
"isReconciled": true,
"amieName": null,
"countryId": 201,
"stateId": null,
"latitude": null,
"longitude": null,
"isMsi": false,
"isActive": true,
"isEligible": true,
"carnegieCategories": [],
"state": null,
"country": "United States",
"orgType": "Academic",
"ignoreIdp": false
}
],
"idps": [
{"displayName": "Example University", "entityId": "urn:mace:example.edu"}
]
}The JWT is invalid.
The domain is not known to ACCESS/CILogon.
Get the active terms and conditions for ACCESS.
Authorization: containing a JWT of typeotporlogin.
Return the active terms and conditions.
{
"id": 1,
"description": "ACCESS Terms and Conditions",
"url": "https://access-ci.org/terms",
"body": "Full text of the terms and conditions..."
}The JWT is invalid.
No active terms and conditions found.
Get a list of all selectable IANA time zone identifiers. The list is generated
from the system time zone database with Python's zoneinfo module, filtered to
the identifiers CoManage Registry accepts: those listed in the database's
zone.tab, plus UTC. That excludes deprecated aliases (Asia/Calcutta,
Europe/Kiev), country groupings (US/Eastern), legacy single-name zones
(Egypt), and fixed-offset zones (Etc/GMT+5), while keeping the current
identifier for every country and region.
Authorization: containing a JWT of typeotporlogin.
Return a list of time zone identifiers, sorted alphabetically.
{
"timeZones": ["Africa/Abidjan", "America/New_York", "UTC"]
}The JWT is invalid.
Receive Amazon SES delivery events (send, delivery, bounce, complaint, delivery delay, reject) relayed by Amazon SNS, and log the SES message ID and status. Events are only logged, not stored. Does not require authentication, but only accepts messages whose TopicArn matches AWS_SNS_TOPIC_ARN and whose SNS signature is valid. Signing certificates are only fetched from the SNS endpoint (https://sns.<region>.amazonaws.com) and are cached per process. When SNS first subscribes the endpoint, the route confirms the subscription automatically.
This route is rate-limited to 1000 requests/hour per IP address.
To set it up in AWS:
- Create an SNS topic, and set
AWS_SNS_TOPIC_ARNto its ARN. - Create an SES configuration set with an SNS event destination pointing at the topic, choosing the event types to log. Set
AWS_SES_CONFIGURATION_SETto its name. Verification emails are sent with this configuration set. - Subscribe
https://<host>/api/v1/email/callbackto the topic with the HTTPS protocol.
An SNS message (Notification, SubscriptionConfirmation, or UnsubscribeConfirmation), sent by SNS with a text/plain content type.
The event was logged, or the subscription was confirmed.
The body is not a valid SNS message, or the subscription could not be confirmed.
The message is not from the configured SNS topic, or its signature is invalid.
The rate limit was exceeded.