OpenID Connect - 403 Forbidden after Expiration of Access Token

Hello Explosion team,

We are working on a web application for a customer, which integrates Prodigy for 5 users using Prodigy company license and the Prodigy company plugins. Prodigy is installed into a dedicated virtual environment and started/stopped via a subprocess. The main web application and Prodigy shall use OpenID Connect against Keycloak. Both applications get their own subdomain using an Nginx reverse proxy.

Like some other user(s) in the forum (Bug Report: Call to get_session_questions fails with 403 Unauthorized,) we also encountered the problem of a response 403 Forbidden for /get_session_questions (or give_answers on saving using the save button):

{
    "error": "Unauthorized",
    "detail": "No token provided. Not authenticated.\n"
}

This error message is caused by no Prodigy authentication cookies present, because they have expired.
Normally there is a cookie __prodigy_cauth_id_token__ and a cookie __prodigy_cauth_access_token__.

It can be temporarily resolved by refreshing the page, but that loses existing annotations that were not transmitted to the backend, yet.

I was able to determine that this recurring problem is strictly coupled to the Access Token Lifespan configured in Keycloak. This value defaults to 1 minute and should be kept at a low value.

So, after one minute or more of idling or annotating without a backend request, the problem occurs. If it takes less than a minute it remains fine. The same I could reproduce for an Access Token Lifespan of e.g. 15 minutes where 14 minutes are fine, but 15 minutes or more cause the error.

In the implementation of the authentication in the Prodigy company plugins source code there is a method ProdigyCompanyAuth.get_tokens_from_auth_code. This returns a UserTokensResponse created from only a small subset of the authentication response payload.

{'access_token': '<ACCESS_TOKEN>', 'expires_in': 60, 'refresh_expires_in': 1800, 'refresh_token': '<REFRESH_TOKEN>', 'token_type': 'Bearer', 'id_token': '<ID_TOKEN>', 'not-before-policy': 0, 'session_state': '<SESSION_STATE>', 'scope': 'openid email profile'}

Of these fields only id_token, access_token, expires_in and token_type are used.
Then the short expires_in value for the access_token is used for both the access token and the ID token cookies as expiration duration.

However the longer lived refresh token is not used at all and discarded. I am not an authentication expert myself, but I found out that usually a refresh token is used to get a new access token when necessary.

Can you give recommendations on how we can mitigate the issue or improve the OIDC plugin?

Hi Martin,

Thanks for the detailed message on this. We'll take a look for you but it sounds reasonable that the token refresh could be improved here.

1 Like

Minimal Working Example

Or rather "not working" maybe

Keycloak

Hosted on http://localhost:8081

  • Go to Manage > Clients and create a client using Create client
  • General settings:
Client type: OpenID Connect
Client ID: prodigy
...
  • Capability config:
Client authentication: Off
Authorization: Off
Authentication flow:
    Standard flow: Checked
    ...
...
  • Login settings:
Root URL:
Home URL:
Valid redirect URIs:
- http://prodigy.127-0-0-1.sslip.io/*
Valid post logout redirect URIs:
Web origins:
  • Save using Save button

Nginx

File prodigy.nginx.conf

server {
	listen 80;
	server_name prodigy.127-0-0-1.sslip.io;

	location / {
		proxy_pass http://127.0.0.1:8080;

		proxy_set_header Host $host;
		proxy_set_header X-Forwarded-Host $host;
		proxy_set_header X-Forwarded-Proto $scheme;
		proxy_set_header X-Forwarded-Port $server_port;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	}

	proxy_buffer_size 1M;
	proxy_buffers 4 1M;
	proxy_busy_buffers_size 2M;
}

Prodigy

File pyproject.toml using uv

[project]
name = "prodigy-oidc-problem-minimal-example"
version = "0.1.0"
description = "Prodigy OIDC Problem Minimal Example"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
    "prodigy",
    "prodigy-company-plugins",
]

[tool.uv.sources]
prodigy = { path = "prodigy-1.18.5-py3-none-any.whl" }
prodigy-company-plugins = { path = "prodigy_company_plugins-1.0.0a1-py3-none-any.whl" }

File .env

PRODIGY_OIDC_AUTH_ENABLED="1"
PRODIGY_OIDC_DISCOVERY_URL="http://localhost:8081/realms/master/.well-known/openid-configuration"
PRODIGY_OIDC_CLIENT_ID="prodigy"
PRODIGY_OIDC_CLIENT_SECRET="secret"
PRODIGY_DEPLOYED_URL="http://prodigy.127-0-0-1.sslip.io"

Further file needed: example.txt with arbitrary text, maybe a plain text book e.g. https://www.gutenberg.org/ebooks/1661.txt.utf-8.

set -a; source .env; set +a; uv run prodigy ner.manual example-dataset "blank:en" ./example.txt --label person

Hello Matthew,

thank you for the really quick first response.

Can you provide us with an estimation when you will be able to look into the issue and release a patch of the company plugins?

We will need to implement some temporary workaround and justify this for deployment at the customer.
So, it would help us if we could say "in X months the temporary workaround will not be necessary anymore".

Greetings
Martin

Hi @Martin-S!

Thank you for the report. The reproduction steps and your analysis of get_tokens_from_auth_code were spot on. The refresh token returned by the token endpoint was indeed being discarded, and the cookie lifetimes were tied to the access token's expires_in.

We've fixed this in the company plugins: the refresh token is now stored in an httponly cookie scoped to its own (longer) lifetime, and when the id/access token cookies have expired the server obtains fresh tokens via a refresh_token grant and re-sets the cookies on the response. Now requests like /get_session_questions and /give_answers succeed after idle periods instead of returning 403, and no annotations are lost.

Regarding timing: we expect to ship a patch release of prodigy-company-plugins by the end of this week, so your temporary workaround should only be needed until then.

Thanks again for the thorough write-up!

1 Like

Hi @Martin-S,

We've just released prodigy-company-plugins 0.5.2 with the fix. Upgrading is all that's needed. No changes to your Keycloak client or Nginx config should be necessary, and you can keep Access Token Lifespan at its low value.

One thing worth knowing: your session is now bounded by the refresh token's lifetime rather than the access token's. In your payload refresh_expires_in is 1800, which comes from Keycloak's SSO Session Idle. So idling longer than that will still send annotators back to login — if 30 minutes is too short for your annotators, that's the value to raise.

Let us know if it doesn't solve your issue, and thank you again for the report.

1 Like

Hello Magda,

thank you very much for the fast fix and patch release!
I only noticed your replies today, because by default my notification settings did not enable e-mails being sent.

I can confirm that indeed with the released patch 0.5.2 of the company plugins, the behavior when using OpenID Connect is as expected (based on testing with my minimal working example).

Best regards
Martin