Authenticate and authorize the clients#

Use this guide to secure the SCIM endpoints of an application: refuse the unknown clients, and limit what each known client may do. It is meant for developers who serve scim2-server from a web application. It assumes an integration built with Integrate a web framework, and a way to validate the credentials of the clients, such as the tokens of Authlib.

The guide does not cover how to issue the tokens, nor the access to single attributes. Serve the /Me endpoint covers /Me.

The client gets a 401 when its credentials are missing or invalid, and a 403 when it is known but may not perform the operation.

Refuse the unknown clients#

Authenticate the client before the handler. The sketches of this guide describe the client with one object. verify_token stands for the code that validates a token and returns this object, or None for an invalid token. Load the scopes and the permissions of the client at this point: Authorize each operation reads them.

>>> from dataclasses import dataclass

>>> @dataclass
... class Client:
...     user_id: str | None
...     scopes: set[str]
...     organization: str

Raise UnauthorizedException when the credentials are missing or invalid. The error handler of Integrate a web framework turns it into a SCIM error. Leave /ServiceProviderConfig open: per RFC 7643 §5, the authentication schemes should be readable without authentication. match() tells which operation a request asks for:

@scim.before_request
def authenticate():
    g.client = None
    if service.match(scim_request()).operation is Operation.service_provider_config:
        return
    authorization = request.authorization
    if authorization is not None and authorization.type == "bearer":
        g.client = verify_token(authorization.token)
    if g.client is None:
        raise UnauthorizedException
async def authenticate(request: Request) -> Client | None:
    scim_req = await scim_request(request)
    if service.match(scim_req).operation is Operation.service_provider_config:
        return None
    scheme, _, token = request.headers.get("authorization", "").partition(" ")
    client = await verify_token(token) if scheme.lower() == "bearer" else None
    if client is None:
        raise UnauthorizedException
    return client


scim = APIRouter(dependencies=[Depends(authenticate)])

scim is the blueprint of the SCIM routes with Flask, and their router with FastAPI. With WSGIApplication or ASGIApplication, override check_auth() instead.

Pass the client to the service#

Pass the client in the subject of the request. The service does not read it. It passes the request to the methods that an application overrides, such as authorize() and me_target():

@scim.route("/", defaults={"path": ""}, methods=METHODS)
@scim.route("/<path:path>", methods=METHODS)
def serve(path):
    scim_req = scim_request()
    scim_req.subject = g.client
    return to_response(handler.handle(scim_req))
@scim.api_route("/{path:path}", methods=METHODS)
async def serve(request: Request, client: Client | None = Depends(authenticate)):
    scim_req = await scim_request(request)
    scim_req.subject = client
    return to_response(await handler.handle(scim_req))

FastAPI calls authenticate once per request, for the router and for the view. With WSGIApplication or ASGIApplication, override get_subject() instead.

Authorize each operation#

Override authorize() to check the rights of the client on each operation. The service calls it for every operation of a request, every operation of a bulk request, and every resource type of a search at the root. Raise ForbiddenException to refuse the operation.

The following service maps each resource type to a scope for reading and a scope for writing, such as scim:User:read. A client may also read its own user without any scope:

>>> from scim2_models import ForbiddenException
>>> from scim2_server.routing import Operation
>>> from scim2_server.service import ScimService

>>> READS = {Operation.query, Operation.search, Operation.search_with_body}

>>> class ScopedService(ScimService):
...     def authorize(self, request, target, resource_type):
...         client = request.subject
...         own_user = target.resource_id == client.user_id
...         if target.operation is Operation.query and own_user:
...             return
...         action = "read" if target.operation in READS else "write"
...         if f"scim:{resource_type.name}:{action}" not in client.scopes:
...             raise ForbiddenException

In a bulk request, a refused operation fails with a 403, and the other operations run (RFC 7644 §3.7.3). The following client may manage the users, but not the groups:

>>> import json
>>> from scim2_server.handler import ScimHandler
>>> from scim2_server.memory import InMemoryStorage
>>> from scim2_server.requests import ScimRequest
>>> from scim2_server.utils import load_default_provider

>>> handler = ScimHandler(ScopedService(load_default_provider()), InMemoryStorage())
>>> client = Client(
...     user_id=None,
...     scopes={"scim:User:read", "scim:User:write"},
...     organization="example",
... )
>>> body = {
...     "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"],
...     "Operations": [
...         {
...             "method": "POST",
...             "path": "/Users",
...             "bulkId": "user",
...             "data": {"userName": "bjensen"},
...         },
...         {
...             "method": "POST",
...             "path": "/Groups",
...             "bulkId": "group",
...             "data": {"displayName": "admins"},
...         },
...     ],
... }
>>> response = handler.handle(
...     ScimRequest(
...         "POST",
...         "https://scim.example/v2",
...         "/Bulk",
...         headers={"Content-Type": "application/scim+json"},
...         body=json.dumps(body).encode(),
...         subject=client,
...     )
... )
>>> [operation["status"] for operation in response.body["Operations"]]
['201', '403']

A search at the root leaves out the resource types that authorize() refuses. The search answers 403 when authorize() refuses every type:

>>> response = handler.handle(
...     ScimRequest("GET", "https://scim.example/v2", "/", subject=client)
... )
>>> [resource["userName"] for resource in response.body["Resources"]]
['bjensen']

Read the operation in the target, whatever the URL of the request:

  • in a bulk request, the method of the request is always POST, and its path /Bulk;

  • in a request on /Me, the target holds the resource of the subject, and Target.me is True.

authorize() must not do any input or output: read the rights that verify_token loaded. The layers of the server explains why.

Restrict the access to some resources#

Some rules depend on the stored resources, such as a client that only manages the users of its own organization (RFC 7644 §2). authorize() cannot apply them: it does not read the storage. Apply them in a storage bound to the organization of the client, such as OrganizationStorage. Build the handler for each request, with this storage and the service of Authorize each operation. A handler holds no state. The client is None on /ServiceProviderConfig, and the handler does not call the storage there:

service = ScopedService(provider)


@scim.route("/", defaults={"path": ""}, methods=METHODS)
@scim.route("/<path:path>", methods=METHODS)
def serve(path):
    organization = g.client.organization if g.client else None
    handler = ScimHandler(service, OrganizationStorage(db.session, organization))
    scim_req = scim_request()
    scim_req.subject = g.client
    return to_response(handler.handle(scim_req))
service = ScopedService(provider)


@scim.api_route("/{path:path}", methods=METHODS)
async def serve(
    request: Request,
    client: Client | None = Depends(authenticate),
    session=Depends(get_session),
):
    organization = client.organization if client else None
    handler = AsyncScimHandler(service, OrganizationStorage(session, organization))
    scim_req = await scim_request(request)
    scim_req.subject = client
    return to_response(await handler.handle(scim_req))

OrganizationStorage raises NotFoundException for a resource of another organization. The client gets a 404, as for a resource that does not exist:

  • a search only counts the resources of the organization in totalResults;

  • in a bulk request, an operation on another organization fails with a 404, and the other operations run.

Announce the schemes#

List the authentication schemes in the authentication_schemes of the provider. The /ServiceProviderConfig endpoint publishes them, and each 401 response carries a WWW-Authenticate header with one challenge per Bearer or Basic scheme (RFC 7644 §2):

>>> from scim2_models import AuthenticationScheme
>>> from scim2_models import UnauthorizedException

>>> provider = load_default_provider()
>>> provider.config.authentication_schemes = [
...     AuthenticationScheme(
...         type=AuthenticationScheme.Type.oauthbearertoken,
...         name="OAuth Bearer Token",
...         description="Authentication with an OAuth 2.0 bearer token",
...     )
... ]
>>> service = ScopedService(provider)
>>> service.error_response(UnauthorizedException()).headers["WWW-Authenticate"]
'Bearer realm="SCIM"'

To announce another scheme, or to add parameters to the challenge, override www_authenticate(). For instance, the resource_metadata parameter of RFC 9728 §5.1 tells the clients where to discover the authorization server:

>>> class ProtectedResourceService(ScopedService):
...     def www_authenticate(self, exception):
...         metadata = "https://scim.example/.well-known/oauth-protected-resource/scim/v2"
...         return f'Bearer resource_metadata="{metadata}"'

>>> service = ProtectedResourceService(provider)
>>> service.error_response(UnauthorizedException()).headers["WWW-Authenticate"]
'Bearer resource_metadata="https://scim.example/.well-known/oauth-protected-resource/scim/v2"'

The application serves the metadata document itself, outside of the SCIM endpoints.