Authentication (AuthN) and Authorization (AuthZ) is a common challenge when developing microservices. In this article, we will explore how to leverage Okta for AuthN and Open Policy Agent (OPA) for AuthZ.
Contents
Problem Statement
We will develop a FastAPI microservice with 2 APIs /sayhello and /saysecret. Clients having a valid access token from the authentication server (using Okta) will be authorized (using OPA) to perform only /sayhello API.
Okta Introduction
- Okta (https://www.okta.com/) is a secure identity cloud that links all apps logins and devices into a single pane of glass
- Okta runs in the cloud and integrates with directories and identity management systems
- Okta features include Single Sign On (SSO), LDAP integration, Multi factor authentication (MFA) etc.
- Okta Single Sign-On lets users access any app with a single set of credentials
- Okta plays the role of authentication server for secure access to API and microservices by authenticating the user and providing access token
The usual OAuth 2.0 grant flow looks like this:
Step 5: Now the app for integration is ready. Note down the Okta domain, Client ID and Client Secret for integration into microservice.
Step 6: Navigate to Security/ API in Okta dashboard and configure the required scopes. We will use default authorization server for this example.
Step 7: Edit the default authorization server and configure the. required scopes. In this example, we have added a new scope "read_sayhello" which we will use in our application.
Now that we have working Okta and OPA services, let us move on to develop the Python FastAPI microservice. In Python FastAPI is a modern, high performance framework to build microservices. Example below provides a simple microservice built with FastAPI which supports two APIs "/sayhello" and "/saysecret" and returns a JSON response.
- Client requests authorization from the resource owner (usually the user).
- If the user gives authorization, the client passes the authorization grant to the authorization server (in this case Okta).
- If the grant is valid, the authorization server returns an access token, possibly alongside a refresh and/or ID token.
- The client now uses that access token to access the resource server.
Open Policy Agent (OPA) Introduction
- The Open Policy Agent (OPA) is an open source, general-purpose policy engine that can be leveraged for policy enforcement on microservices.
- OPA provides a high-level declarative language (Rego) that lets to specify policy as code and supports simple APIs to offload policy decision-making from the microservices.
- In a typical scenario, when a microservice needs to make policy decisions (E.g. Is the user authorized to perform this API request) it queries OPA and supplies JSON data as input. OPA evaluates the policy decisions by evaluating the input against configured policies and data. OPA response can be configured to provide yes/ no or allow/ deny decisions.
- Rego (https://www.openpolicyagent.org/docs/latest/policy-language/) is the declarative query language to define policies. Rego provides support for referencing nested documents and ensures queries are correct and unambiguous.
Let us say we are writing a microservice which exposes APIs for the clients to consume. OPA is used to enforce the authorization policy for the APIs.
- OPA gets bootstrapped with a Policy document written in Rego which contains the authorization rules. OPA supports Policy API which can be used to add, modify or delete policies.
- OPA policies make policy decisions based on hierarchical structured data. Data can be loaded from outside world and this is referred as base documents. There are also documents computed by policies which are referred as virtual documents. OPA supports Data API to read, write and update documents.
- Microservice queries OPA for a decision. Query is typically accompanied with attributes like HTTP method, path, user etc.
- OPA uses the policies and data documents and responds with an allow or deny decision to the requesting microservices.
Register application in Okta
In this section we will look at the steps required to register an application in Okta. We are building a server side application which would use OAuth 2.0 authentication through API endpoints.
Step 1: Sign in to Okta developer account https://developer.okta.com
Step 2: Create a Web App using "Add Web App".
Step 6: Navigate to Security/ API in Okta dashboard and configure the required scopes. We will use default authorization server for this example.
Step 8: Get access token from Okta
Validate the Okta configuration by requesting for access token.
curl --location --request POST 'https://dev-33448303.okta.com/oauth2/default/v1/token?grant_type=client_credentials&client_id=[client-id]&client_secret=[client_secret]&scope=read_sayhello' \ > --header 'Content-Type: application/x-www-form-urlencoded'
Now we have the Okta setup ready for integration.
OPA as Docker container
It is easy to run OPA as a Docker container. The command below brings up an instance of OPA engine on port 8181.$ docker run -p 8181:8181 openpolicyagent/opa run --server --log-level debug {"addrs":[":8181"],"diagnostic-addrs":[],"level":"info","msg":"Initializing server.","time":"2021-12-07T06:04:19Z"} {"level":"debug","msg":"maxprocs: Leaving GOMAXPROCS=6: CPU quota undefined","time":"2021-12-07T06:04:19Z"} {"headers":{"Content-Type":["application/json"],"User-Agent":["Open Policy Agent/0.35.0 (linux, amd64)"]},"level":"debug","method":"POST","msg":"Sending request.","time":"2021-12-07T06:04:19Z","url":"https://telemetry.openpolicyagent.org/v1/version"} {"level":"debug","msg":"Server initialized.","time":"2021-12-07T06:04:19Z"} {"headers":{"Content-Length":["216"],"Content-Type":["application/json"],"Date":["Tue, 07 Dec 2021 06:04:21 GMT"]},"level":"debug","method":"POST","msg":"Received response.","status":"200 OK","time":"2021-12-07T06:04:21Z","url":"https://telemetry.openpolicyagent.org/v1/version"} {"current_version":"0.35.0","level":"debug","msg":"OPA is up to date.","time":"2021-12-07T06:04:21Z"}Validate if OPA service is running by accessing http://localhost:8181/ which should display the OPA page.
Now that we have OPA service running, next step is to deploy a policy. Let us create a sample Rego policy which denies all requests except the /sayhello GET API.
package demoapi.authz default allow = false allow { input.method == "GET" input.api == "/sayhello" token.payload.scp[_] = "read_sayhello" } token = {"payload": payload} { [header, payload, signature] := io.jwt.decode(input.jwt.tokenValue) }Next step is to deploy this policy on to the OPA service. We will use the OPA REST APIs for this https://www.openpolicyagent.org/docs/latest/rest-api/.
Use the "Create Policy" API to deploy the policy.
Rego playground can be used to validate the policy against the token from Okta server.
Copy the policy created earlier into the playground. Specify the input JSON fields and provide the access token received from Okta earlier. If the API is "/sayhello" policy validates to allow as true.
To run this example need to install these modules.
pip install fastapipip install uvicornpip install httpxpip install okta-jwt
Here uvicorn is an implementation of ASGI (Asynchronous Service Gateway Interface) specifications and provides a standard interface between async-capable Python web servers, frameworks and applications.
Application settings
First step is to define an application settings (app.env) file to hold the Okta and OPA settings. This includes the Okta issuer, Okta client id and Okta client secret received during the application registration. Also the OPA authorization URL is configured in this settings file. Also note that we have used the Rego package name created earlier on OPA services in the Auth URL.
[Okta] OKTA_CLIENT_ID=**** OKTA_CLIENT_SECRET=**** OKTA_ISSUER=https://dev-33448303.okta.com/oauth2/default OKTA_AUDIENCE=api://default [Opa] OPA_AUTHZ_URL=http://localhost:8181/v1/data/demoapi/authz/allow
Application with local validation of JWT
Next step is to define the FastAPI microservices (app.py). There are 2 APIs with a dependency to validate method. In validate, we check the JWT for authentication then make an API call to OPA service. Based on the allow/ deny decision from OPA service a decision is made to serve the client request.from fastapi import Depends, FastAPI, HTTPException, Request from okta_jwt.jwt import validate_token as validate_locally from fastapi.security import OAuth2PasswordBearer import uvicorn import configparser import httpx import json app = FastAPI() # Define the auth scheme and access token URL oauth2_scheme = OAuth2PasswordBearer(tokenUrl='token') # Load environment variables config = configparser.ConfigParser() config.read('app.env') def validate(request: Request, token: str = Depends(oauth2_scheme)): try: # AuthN: Validate JWT token locally auth_res = validate_locally( token, config.get('Okta', 'OKTA_ISSUER'), config.get('Okta', 'OKTA_AUDIENCE'), config.get('Okta', 'OKTA_CLIENT_ID') ) if bool(auth_res) is False: return False # AuthZ: Validate with defined policies data = { "input": { "method": request.method, "api": request.url.path, "jwt": { "tokenValue": token } } } opa_url = config.get('Opa', 'OPA_AUTHZ_URL') headers = { 'accept': 'application/json' } authz_response = httpx.post(opa_url, headers=headers, data=json.dumps(data)) if authz_response: authz_json = json.loads(authz_response.text) return bool(authz_json["result"]) else: return False except Exception as e: print("Error: " + str(e)) raise HTTPException(status_code=403) @app.get("/sayhello") async def sayhello(valid: bool = Depends(validate)): if valid: return {"message": "Hello there!!"} else: raise HTTPException(status_code=403) @app.get("/saysecret") async def saysecret(valid: bool = Depends(validate)): if valid: return {"message": "This is a secret"} else: raise HTTPException(status_code=403) if __name__ == '__main__': uvicorn.run('app:app', host='127.0.0.1', port=8086, reload=True)
Application with remote validation of JWT
Only change in the code flow below is to validate the JWT token with Okta using /introspect API call. This endpoint takes an access token and returns whether the. token is active or not. Every API call will require a validation with Okta using this approach.from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.security import OAuth2PasswordBearer import uvicorn import configparser import httpx import json app = FastAPI() # Define the auth scheme and access token URL oauth2_scheme = OAuth2PasswordBearer(tokenUrl='token') # Load environment variables config = configparser.ConfigParser() config.read('app.env') def validate_remotely(token, issuer, clientId, clientSecret): headers = { 'accept': 'application/json', 'cache-control': 'no-cache', 'content-type': 'application/x-www-form-urlencoded', } data = { 'client_id': clientId, 'client_secret': clientSecret, 'token': token, } url = issuer + '/v1/introspect' response = httpx.post(url, headers=headers, data=data) return response.status_code == httpx.codes.OK and response.json()['active'] def validate(request: Request, token: str = Depends(oauth2_scheme)): try: # AuthN: Validate JWT token locally auth_res = validate_remotely( token, config.get('Okta', 'OKTA_ISSUER'), config.get('Okta', 'OKTA_CLIENT_ID'), config.get('Okta', 'OKTA_CLIENT_SECRET') ) if auth_res is False: return False # AuthZ: Validate with defined policies data = { "input": { "method": request.method, "api": request.url.path, "jwt": { "tokenValue": token } } } opa_url = config.get('Opa', 'OPA_AUTHZ_URL') headers = { 'accept': 'application/json' } authz_response = httpx.post(opa_url, headers=headers, data=json.dumps(data)) if authz_response: authz_json = json.loads(authz_response.text) return bool(authz_json["result"]) else: return False except Exception as e: print("Error: " + str(e)) raise HTTPException(status_code=403) @app.get("/sayhello") async def sayhello(valid: bool = Depends(validate)): if valid: return {"message": "Hello there!!"} else: raise HTTPException(status_code=403) @app.get("/saysecret") async def saysecret(valid: bool = Depends(validate)): if valid: return {"message": "This is a secret"} else: raise HTTPException(status_code=403) if __name__ == '__main__': uvicorn.run('app:app', host='127.0.0.1', port=8086, reload=True)
Putting it all together and testing
Now, let us test the application and examine.GET Access Token
This API is used to request for an access token from Okta.$ curl --location --request POST 'https://dev-33448303.okta.com/oauth2/default/v1/token?grant_type=client_credentials&client_id=add_client_id_here&client_secret=add_client_secret_here&scope=read_sayhello' --header 'Content-Type: application/x-www-form-urlencoded' Returns the access token from Okta
GET request on /sayhello
This API is allowed by the OPA policy and responds with the output.$ curl --location --request GET 'http://localhost:8086/sayhello' --header 'Authorization: Bearer add_access_token_here' Hello there!!
GET request on /saysecret
This API is denied by the OPA policy and responds with the 403 status code.$ curl --location --request GET 'http://localhost:8086/saysecret' --header 'Authorization: Bearer add_access_token_here' No output. Status code 403.Get access to the source code of this example in GitHub.
Conclusion
Okta plays the role of authentication server for secure access to API and microservices by authenticating the user and providing access token. OPA lets to specify policy as code and supports simple APIs to offload policy decision-making from the microservices.
Great blog! Thanks for sharing :)
ReplyDelete