OAuth scopes (developer notes)¶
How Invenio represents OAuth2 scopes, how KCWorks can register them for the token settings UI, and how to read them on an API request. Operator-facing credential guidance lives in API credentials and CLI authentication.
Important
Today, scopes on personal / OAuth tokens are largely decorative for the RDM REST API: they are stored on the token and shown in the UI, but almost no endpoint requires them. Authorization still comes from the owning user’s identity and permission policies. Enforcing scopes on API traffic is planned separately; this page is the mechanism reference for that work.
Packages involved¶
Package |
Role |
|---|---|
invenio-oauth2server |
Token and client models, scope registry, settings UI choices, REST |
Flask-OAuthlib-Invenio ( |
Invenio’s fork of Flask-OAuthlib. Provider that loads the token row, attaches it to the oauthlib request as |
oauthlib |
Lower-level OAuth2 request/verify primitives used by the provider |
invenio-rdm-records |
Registers the internal scope |
KCWorks already depends on these via the InvenioRDM stack. Lockfile name for
the Flask provider is flask-oauthlib-invenio; import path remains
flask_oauthlib.
What a scope is¶
A scope is an invenio_oauth2server.models.Scope instance:
id_— stable string stored on tokens (e.g.user:email,records:read)group— UI grouping labelhelp_text— UI descriptioninternal=False— ifTrue, hidden from the token-creation UI
Treat scope ids as a public interface. Renaming or removing an id after
tokens reference it breaks those tokens (Token.scopes /
Client.default_scopes call validate_scopes()).
Upstream built-ins today:
user:email— from invenio-oauth2server (shown in UI)tokens:generate— from invenio-rdm-records (internal=True)
Registration (so scopes appear in the token UI)¶
On API/UI app init, invenio-oauth2server loads every entry point in the group
invenio_oauth2server.scopes and calls register_scope() on each loaded
object. The settings UI lists non-internal scopes via
current_oauth2server.scope_choices().
Option A — one entry point per scope (upstream style)¶
Define scopes in a module, e.g. site/kcworks/oauth/scopes.py:
from invenio_i18n import lazy_gettext as _
from invenio_oauth2server.models import Scope
records_read_scope = Scope(
id_="records:read",
group="records",
help_text=_("Read published records."),
)
Register in the root pyproject.toml:
entry-points."invenio_oauth2server.scopes".records_read = "kcworks.oauth.scopes:records_read_scope"
Each entry point’s .load() must return a single Scope (not a list).
Reinstall / refresh the editable install so entry points are visible.
Option B — register in code¶
Keep a list (or module constants) and register during app setup
(init_app / finalize_app / api_finalize_app):
from invenio_oauth2server.proxies import current_oauth2server
for scope in MY_SCOPES:
current_oauth2server.register_scope(scope)
Same registry and UI as entry points. Useful if you do not want a long
pyproject.toml block. Register only once (finalize hooks can run in ways that
make double-registration assert if the id is already present).
Hiding scopes from the UI¶
Pass internal=True. The scope remains registered and valid on tokens, but
scope_choices() omits it by default (same pattern as tokens:generate).
Runtime: how a request exposes token scopes¶
On the API app, invenio-oauth2server registers
verify_oauth_token_and_set_current_user as a before_request hook.
If the client sent a bearer / access token, the provider loads the
Tokenrow and, on success, setsrequest.oauth.If there is no token, the hook does nothing; the request continues as anonymous. It does not by itself return 401.
Whether the caller may perform the action is still decided later by the service permission policy (and related checks). That is why write endpoints feel like they “require a token” even though the OAuth hook is optional.
When request.oauth is set:
Attribute |
Meaning |
|---|---|
|
The invenio-oauth2server |
|
The token’s scopes — use this |
|
Scopes the verifier was asked to require for this call — not the token’s scopes. The global hook calls verify with |
KCWorks’s static inbound shim (when configured) sets a stand-in request.oauth
with access_token.scopes so later code can treat it like a token request.
That stand-in may not define request.oauth.scopes; always read
access_token.scopes.
Decorator-based enforcement (views you control)¶
Upstream’s only built-in enforcement helper:
from invenio_oauth2server.decorators import require_api_auth, require_oauth_scopes
@blueprint.route("/something")
@require_api_auth()
@require_oauth_scopes("records:read")
def my_view():
...
@require_api_auth()— require an authenticated user (401 if not). Needed because the global token hook does not require a token, and@require_oauth_scopesalone skips its check whenrequest.oauthis missing.@require_oauth_scopes("id", ...)— ifrequest.oauthis set, require all listed ids to be present onrequest.oauth.access_token.scopes(403 if not). Ifrequest.oauthis missing, the decorator does not block.
Most RDM REST resources do not use these decorators; they rely on permission policies. Use the decorators on KCWorks-owned views when you want view-local scope checks. You cannot decorate arbitrary upstream view callables without forking or wrapping them.
Do not rely on Flask-OAuthlib’s verify_request(scopes=[...]) for “all of
these scopes”: its bearer check treats required scopes as any overlap, and
a failed check leaves the request unauthenticated rather than returning 403.
Prefer @require_oauth_scopes or an explicit subset check on
access_token.scopes.
Discovery in a before_request hook (central enforcement)¶
For API-wide enforcement without decorating every upstream resource, a
before_request hook on the API app (e.g. from api_finalize_app in
site/kcworks/ext.py) can:
Run after oauth2server’s verify hook (append to
before_request_funcs, or callverify_oauth_token_and_set_current_user()at the top — it is idempotent viarequest.oauth_verify_has_run).No-op unless
request.oauthis set (leave anonymous / non-token traffic alone).No-op if
request.url_ruleisNone(unmatched path).Read
token_scopes = set(request.oauth.access_token.scopes).Resolve required scopes from something you own (map of
request.url_rule.rule+request.method→ scope ids). Upstream does not provide route→scope discovery; required scopes are only whatever you pass to@require_oauth_scopesor compute yourself.If the required set is not a subset of
token_scopes, return 403 (or log in an audit mode).
Flask has already matched the URL rule before before_request runs, so
request.url_rule.rule and request.method are available for that map.
Static token shim vs real tokens¶
Both paths can set request.oauth. A successful static-token match uses the
stand-in’s scopes; otherwise the real token’s scopes remain. Scope checks that
key off request.oauth therefore apply to both unless you special-case them.
Scopes vs permission policies¶
Layer |
Question it answers |
|---|---|
Permission policy |
May this identity (user / roles) perform this action? |
OAuth scopes |
May this token perform this class of action? |
Putting scopes only into the usual permission-policy need lists is awkward:
those lists are OR’d, so an Administration (or owner) need would still allow
a broad identity even when the token lacks the scope. Token-narrower-than-account
needs an extra gate when request.oauth is present (decorator, central
hook, or a custom AND-style permission wrapper)—not just another OR need.
Quick reference¶
Task |
Mechanism |
|---|---|
Show scope in token UI |
Register non-internal |
Hide from UI but keep valid |
|
Require scopes on a KCWorks view |
|
Read token scopes in a hook |
|
Required scopes for a route |
|
Identity / role authz |
Unchanged — permission policies on the service; service accounts listed in Service accounts and OAuth scopes |