Skip to content

Tutorials

Authentication and Authorization

Familiarize with the topic

If you don't have prior experience with the topic, we recommend reading Authentication and Authorization in Applications, which is a really good introduction on the difference between Authentication and Authorization that helps you understand how they focus on two different purposes.

This tutorial aims to guide the user to configure fastgeoapi with a mechanism that fits with your security requirements. The tool supports different security schemes for OGC APIs served by pygeoapi and allows optionally to enable a coarse or fine-grade authorization for a collection and the endpoints based on user needs and use cases.

Supported security schemes are:

  • API KEY: mostly used for machine to machine communication where a static shared secret can be kept secured or for internal interactions among microservices;
  • OAuth2: commonly used for authorization to accessing resources between two systems and also for stronger machine to machine communication with external parties when a secret needs to be rotated;
  • OpenID Connect: It looks like very similar to OAuth2 and in fact it is built on top of that. It allows to identify and authenticate a user in mobile and Single-Page Application (SPA).

OAuth2 vs OpenID Connect

It is beneficial to clarify that they serve two different purposes. OAuth2 is a framework for Authorization while OpenID Connect is a protocol for Authentication. If you would like to develop further the concepts then this is an appropriate read.

Configure and protect pygeoapi

The protection mechanisms introduced above are mutually exclusive and they apply to the whole pygeoapi application that is wrapped by fastgeoapi. The configuration happens in the .env file where the environment variables for development and production are defined. As explained in the getting-started section their prefix identifies the target environment (i.e. DEV_ vs PROD_). Let's go through the different mechanisms.

Please make sure to have cloned the repo before starting the following sections. Also, it is supposed to having set the environment for development with ENV_STATE=dev in the .env file.

API KEY

The security configuration, in this case, can be enabled with these two additional settings:

# api-keys
DEV_API_KEY_ENABLED=false
DEV_PYGEOAPI_KEY_GLOBAL=pygeoapi

Setting DEV_API_KEY_ENABLE to true is the way to enable a flat protection to the whole pygeoapi sub-application. The value sets in the DEV_PYGEOAPI_KEY_GLOBAL is the secret key that must be used in the Header X-API-KEY to consume the API.

Start the server with the usual command:

uvicorn app.main:app --host 0.0.0.0 --port 5000 --reload --loop asyncio...
File "<frozen importlib._bootstrap>", line 1204, in _gcd_import
File "<frozen importlib._bootstrap>", line 1176, in _find_and_load
File "<frozen importlib._bootstrap>", line 1147, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 940, in exec_module
File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
File "/Users/francbartoli/code/fastgeoapi/app/main.py", line 188, in <module>
app = create_app()
^^^^^^^^^^^^
File "/Users/francbartoli/code/fastgeoapi/app/main.py", line 138, in create_app
raise ValueError(
ValueError: OPA_ENABLED, JWKS_ENABLED and API_KEY_ENABLED are mutually exclusive

This error is expected since more security schemes at the same time are enabled. Change their value accordingly:

DEV_API_KEY_ENABLED=true
DEV_OPA_ENABLED=false
DEV_JWKS_ENABLED=false

and then start again the server:

uvicorn app.main:app --host 0.0.0.0 --port 5000 --reload --loop asyncio...
2024-02-26 12:46:25.447 | DEBUG | app.main:<module>:190 [id:None] - Global config: DevConfig(ENV_STATE='dev', HOST='0.0.0.0', PORT='5000', ROOT_PATH='', AWS_LAMBDA_DEPLOY=False, LOG_PATH='/tmp', LOG_FILENAME='fastgeoapi.log', LOG_LEVEL='debug', LOG_ENQUEUE=True, LOG_ROTATION='1 days', LOG_RETENTION='1 months', LOG_FORMAT='<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> [id:{extra[request_id]}] - <level>{message}</level>', OPA_ENABLED=False, OPA_URL='http://localhost:8383', APP_URI='http://localhost:5000', OIDC_WELL_KNOWN_ENDPOINT='http://localhost:8282/realms/pygeoapi/.well-known/openid-configuration', OIDC_CLIENT_ID='pygeoapi-client', OIDC_CLIENT_SECRET='2yholx8r3mqyUJaOoJiZhcqvQDQwmgyD', API_KEY_ENABLED=True, JWKS_ENABLED=False, OAUTH2_JWKS_ENDPOINT='https://uat.interop.pagopa.it/.well-known/jwks.json', OAUTH2_TOKEN_ENDPOINT='https://auth.uat.interop.pagopa.it/token.oauth2', PYGEOAPI_KEY_GLOBAL='pygeoapi', PYGEOAPI_BASEURL='http://localhost:5000', PYGEOAPI_CONFIG='pygeoapi-config.yml', PYGEOAPI_OPENAPI='pygeoapi-openapi.yml', PYGEOAPI_SECURITY_SCHEME='http', FASTGEOAPI_CONTEXT='/geoapi')
2024-02-26 12:46:25.448 | INFO | logging:callHandlers:1706 [id:app] - Started server process [171]
2024-02-26 12:46:25.448 | INFO | logging:callHandlers:1706 [id:app] - Started server process [171]
2024-02-26 12:46:25.448 | INFO | logging:callHandlers:1706 [id:app] - Waiting for application startup.
2024-02-26 12:46:25.448 | INFO | logging:callHandlers:1706 [id:app] - Waiting for application startup.
2024-02-26 12:46:25.449 | INFO | logging:callHandlers:1706 [id:app] - Application startup complete.
2024-02-26 12:46:25.449 | INFO | logging:callHandlers:1706 [id:app] - Application startup complete.

Let's get testing one of the collection available in the pygeoapi-config.yml (i.e. obs) without or with the API-KEY:

curl http://localhost:5000/geoapi/collections/obs -vv
* Trying [::1]:5000...
* connect to ::1 port 5000 failed: Connection refused
* Trying 127.0.0.1:5000...
* Connected to localhost (127.0.0.1) port 5000
> GET /geoapi/collections/obs HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< date: Mon, 26 Feb 2024 14:22:28 GMT
< server: uvicorn
< content-length: 23
< content-type: application/json
<
* Connection #0 to host localhost left intact
{"detail":"no api key"}

Using the API-KEY we are overcoming the unauthorized error and getting the expected response:

# This time we are passing the correct security scheme with the secretcurl -H "X-API-KEY: pygeoapi" http://localhost:5000/geoapi/collections/obs -vv
* Trying [::1]:5000...
* connect to ::1 port 5000 failed: Connection refused
* Trying 127.0.0.1:5000...
* Connected to localhost (127.0.0.1) port 5000
> GET /geoapi/collections/obs HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/8.4.0
> Accept: */*
> X-API-KEY: pygeoapi
>
< HTTP/1.1 200 OK
< date: Mon, 26 Feb 2024 14:25:14 GMT
< server: uvicorn
< content-length: 3552
< content-type: application/json
< x-powered-by: pygeoapi 0.16.dev0
< content-language: en-US
<
{
"id":"obs",
"title":"Observations",
"description":"My cool observations",
"keywords":[
"observations",
"monitoring"
],
"links":[
{
"type":"text/csv",
"rel":"canonical",
"title":"data",
"href":"https://github.com/mapserver/mapserver/blob/branch-7-0/msautotest/wxs/data/obs.csv",
"hreflang":"en-US"
},
{
"type":"text/csv",
"rel":"alternate",
"title":"data",
"href":"https://raw.githubusercontent.com/mapserver/mapserver/branch-7-0/msautotest/wxs/data/obs.csv",
"hreflang":"en-US"
},
{
"type":"application/json",
"rel":"root",
"title":"The landing page of this server as JSON",
"href":"http://localhost:5000/geoapi?f=json"
},
{
"type":"text/html",
"rel":"root",
"title":"The landing page of this server as HTML",
"href":"http://localhost:5000/geoapi?f=html"
},
{
"type":"application/json",
"rel":"self",
"title":"This document as JSON",
"href":"http://localhost:5000/geoapi/collections/obs?f=json"
},
{
"type":"application/ld+json",
"rel":"alternate",
"title":"This document as RDF (JSON-LD)",
"href":"http://localhost:5000/geoapi/collections/obs?f=jsonld"
},
{
"type":"text/html",
"rel":"alternate",
"title":"This document as HTML",
"href":"http://localhost:5000/geoapi/collections/obs?f=html"
},
{
"type":"application/schema+json",
"rel":"http://www.opengis.net/def/rel/ogc/1.0/queryables",
"title":"Queryables for this collection as JSON",
"href":"http://localhost:5000/geoapi/collections/obs/queryables?f=json"
},
{
"type":"text/html",
"rel":"http://www.opengis.net/def/rel/ogc/1.0/queryables",
"title":"Queryables for this collection as HTML",
"href":"http://localhost:5000/geoapi/collections/obs/queryables?f=html"
},
{
"type":"application/geo+json",
"rel":"items",
"title":"items as GeoJSON",
"href":"http://localhost:5000/geoapi/collections/obs/items?f=json"
},
{
"type":"application/ld+json",
"rel":"items",
"title":"items as RDF (GeoJSON-LD)",
"href":"http://localhost:5000/geoapi/collections/obs/items?f=jsonld"
},
{
"type":"text/html",
"rel":"items",
"title":"Items as HTML",
"href":"http://localhost:5000/geoapi/collections/obs/items?f=html"
}
],
"extent":{
"spatial":{
"bbox":[
[
-180,
-90,
180,
90
]
],
"crs":"http://www.opengis.net/def/crs/OGC/1.3/CRS84"
},
"temporal":{
"interval":[
[
"2000-10-30T18:24:39+00:00",
"2007-10-30T08:57:29+00:00"
]
]
}
},
"itemType":"feature",
"crs":[
"http://www.opengis.net/def/crs/OGC/1.3/CRS84"
],
"storageCRS":"http://www.opengis.net/def/crs/OGC/1.3/CRS84"
* Connection #0 to host localhost left intact
}

OAuth2

In this case we are challenging the client to present valid JSON Web Tokens (JWTs) and getting to verify the token’s signature against the public key with the JSON Web Key Set (JWKS) endpoint of the authorization server.

Change the .env file with these settings:

DEV_API_KEY_ENABLED=false
DEV_OPA_ENABLED=false
DEV_JWKS_ENABLED=true

And configure a valid JWKS and Token endpoint for the authorization server:

Use OAuth2 playground

There are some playgrounds available which can be used for the sake of testing the workflow. Let's use the one from Auth0 by Okta.

# oidc-jwks-only
DEV_JWKS_ENABLED=true
DEV_OAUTH2_JWKS_ENDPOINT=https://samples.auth0.com/.well-known/jwks.json
DEV_OAUTH2_TOKEN_ENDPOINT=https://samples.auth0.com/oauth/token

Use the OAuth2 server infrastructure to get the access token and then use that to consume the protected resource from the fastgeoapi server.

Let's get testing the collection again:

# This time we are passing the OAuth2 security scheme with the retrieved tokencurl -H "Authorization: Bearer <access_token>" http://localhost:5000/geoapi/collections/obs -vv
* Trying [::1]:5000...
* connect to ::1 port 5000 failed: Connection refused
* Trying 127.0.0.1:5000...
* Connected to localhost (127.0.0.1) port 5000
> GET /geoapi/collections/obs HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/8.4.0
> Accept: */*
> X-API-KEY: pygeoapi
>
< HTTP/1.1 200 OK
< date: Mon, 26 Feb 2024 14:25:14 GMT
< server: uvicorn
< content-length: 3552
< content-type: application/json
< x-powered-by: pygeoapi 0.16.dev0
< content-language: en-US
<
{
"id":"obs",
"title":"Observations",
"description":"My cool observations",
"keywords":[
"observations",
"monitoring"
],
"links":[
{
"type":"text/csv",
"rel":"canonical",
"title":"data",
"href":"https://github.com/mapserver/mapserver/blob/branch-7-0/msautotest/wxs/data/obs.csv",
"hreflang":"en-US"
},
{
"type":"text/csv",
"rel":"alternate",
"title":"data",
"href":"https://raw.githubusercontent.com/mapserver/mapserver/branch-7-0/msautotest/wxs/data/obs.csv",
"hreflang":"en-US"
},
{
"type":"application/json",
"rel":"root",
"title":"The landing page of this server as JSON",
"href":"http://localhost:5000/geoapi?f=json"
},
{
"type":"text/html",
"rel":"root",
"title":"The landing page of this server as HTML",
"href":"http://localhost:5000/geoapi?f=html"
},
{
"type":"application/json",
"rel":"self",
"title":"This document as JSON",
"href":"http://localhost:5000/geoapi/collections/obs?f=json"
},
{
"type":"application/ld+json",
"rel":"alternate",
"title":"This document as RDF (JSON-LD)",
"href":"http://localhost:5000/geoapi/collections/obs?f=jsonld"
},
{
"type":"text/html",
"rel":"alternate",
"title":"This document as HTML",
"href":"http://localhost:5000/geoapi/collections/obs?f=html"
},
{
"type":"application/schema+json",
"rel":"http://www.opengis.net/def/rel/ogc/1.0/queryables",
"title":"Queryables for this collection as JSON",
"href":"http://localhost:5000/geoapi/collections/obs/queryables?f=json"
},
{
"type":"text/html",
"rel":"http://www.opengis.net/def/rel/ogc/1.0/queryables",
"title":"Queryables for this collection as HTML",
"href":"http://localhost:5000/geoapi/collections/obs/queryables?f=html"
},
{
"type":"application/geo+json",
"rel":"items",
"title":"items as GeoJSON",
"href":"http://localhost:5000/geoapi/collections/obs/items?f=json"
},
{
"type":"application/ld+json",
"rel":"items",
"title":"items as RDF (GeoJSON-LD)",
"href":"http://localhost:5000/geoapi/collections/obs/items?f=jsonld"
},
{
"type":"text/html",
"rel":"items",
"title":"Items as HTML",
"href":"http://localhost:5000/geoapi/collections/obs/items?f=html"
}
],
"extent":{
"spatial":{
"bbox":[
[
-180,
-90,
180,
90
]
],
"crs":"http://www.opengis.net/def/crs/OGC/1.3/CRS84"
},
"temporal":{
"interval":[
[
"2000-10-30T18:24:39+00:00",
"2007-10-30T08:57:29+00:00"
]
]
}
},
"itemType":"feature",
"crs":[
"http://www.opengis.net/def/crs/OGC/1.3/CRS84"
],
"storageCRS":"http://www.opengis.net/def/crs/OGC/1.3/CRS84"
* Connection #0 to host localhost left intact
}

OpenID Connect

OpenID Connect (OIDC) is a protocol built on top of OAuth2 that provides authentication capabilities. It is commonly used for Single-Page Applications (SPA) and mobile applications where user identity verification is required.

In fastgeoapi, OIDC authentication is integrated with Open Policy Agent (OPA) for authorization decisions. This means that:

  1. Authentication: OIDC verifies the user's identity through an Identity Provider (IdP)
  2. Authorization: OPA evaluates policies to determine if the authenticated user can access specific resources

Prerequisites

Before configuring OIDC, you need to have:

  • An OIDC-compliant Identity Provider (e.g., Keycloak, Logto, Auth0)
  • An OPA server running with your authorization policies

For local development, you can use the provided Docker Compose setup that includes Keycloak and OPA:

cd scripts/iam
docker compose up -d

This starts:

  • Keycloak on port 8282 with a pre-configured pygeoapi realm
  • OPA on port 8383 with a sample authorization policy

Configuration

Configure OIDC and OPA in your .env file:

# Enable OPA (required for OIDC)
DEV_OPA_ENABLED=true
DEV_OPA_URL=http://localhost:8383/v1/data/httpapi/authz

# OIDC configuration
DEV_OIDC_WELL_KNOWN_ENDPOINT=http://localhost:8282/realms/pygeoapi/.well-known/openid-configuration
DEV_OIDC_CLIENT_ID=pygeoapi-client
DEV_OIDC_CLIENT_SECRET=2yholx8r3mqyUJaOoJiZhcqvQDQwmgyD

# Disable other authentication mechanisms
DEV_API_KEY_ENABLED=false
DEV_JWKS_ENABLED=false

Mutual Exclusivity

Remember that OPA_ENABLED, API_KEY_ENABLED, and JWKS_ENABLED are mutually exclusive. Only one can be enabled at a time.

Start the server:

uvicorn app.main:app --host 0.0.0.0 --port 5000 --reload --loop asyncio...
2024-02-26 14:30:00.000 | INFO | app.main:<module>:190 - OPA_ENABLED=True
2024-02-26 14:30:00.001 | INFO | logging:callHandlers:1706 [id:app] - Application startup complete.

Testing with curl

When OIDC is enabled, requests require a valid access token. First, obtain a token from your Identity Provider:

# Get an access token from Keycloak
TOKEN=$(curl -s -X POST \
  "http://localhost:8282/realms/pygeoapi/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=pygeoapi-client" \
  -d "client_secret=2yholx8r3mqyUJaOoJiZhcqvQDQwmgyD" \
  -d "username=testuser" \
  -d "password=testpassword" | jq -r '.access_token')

Then use the token to access protected endpoints:

curl -H "Authorization: Bearer $TOKEN" http://localhost:5000/geoapi/collections/obs -vv
* Trying 127.0.0.1:5000...
* Connected to localhost (127.0.0.1) port 5000
> GET /geoapi/collections/obs HTTP/1.1
> Host: localhost:5000
> Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
>
< HTTP/1.1 200 OK
< content-type: application/json
...

Without a valid token, you will receive a 401 Unauthorized response:

curl http://localhost:5000/geoapi/collections/obs -vv
* Trying 127.0.0.1:5000...
* Connected to localhost (127.0.0.1) port 5000
> GET /geoapi/collections/obs HTTP/1.1
> Host: localhost:5000
>
< HTTP/1.1 401 Unauthorized
< content-type: application/json
<
{"message":"Unauthorized"}

How it works

The authentication and authorization flow works as follows:

  1. The client sends a request with an Authorization: Bearer <token> header
  2. fastgeoapi validates the token with the OIDC provider using the well-known endpoint
  3. User claims (e.g., sub, email, company) are extracted from the token
  4. The request details and user claims are sent to OPA for authorization
  5. OPA evaluates the policy and returns an allow: true or allow: false decision
  6. If allowed, the request proceeds to pygeoapi; otherwise, a 401 response is returned

401 vs 403 Response

Currently, the fastapi-opa library returns 401 Unauthorized for both authentication failures and authorization denials. Ideally, authorization denials should return 403 Forbidden. This is a known limitation tracked in issue #321

Configure a coarse or fine-grained authorization

Policies as code

TBD

Back to top