Skip to content

new-serverless-pattern-Lambda-pingidentity-python #2810

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions lambda-pingidentity-python/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 Deepali Tandale

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
160 changes: 160 additions & 0 deletions lambda-pingidentity-python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Simple JWT API with PingOne Integration

A minimal serverless API demonstrating JWT authentication with PingOne using AWS Lambda and API Gateway.

## Architecture

### High-Level Flow
![project2025-Severless-Page-2.jpg](ArchitectureDiagram.jpg)

## Technologies Used

- **AWS Lambda** - Serverless compute
- **API Gateway** - HTTP API with custom authorizer
- **PingOne** - Identity provider and JWT issuer
- **Python 3.9** - Runtime environment
- **AWS SAM** - Infrastructure as Code

### Detailed Authentication Flow

When a client sends an HTTP request to a protected API, it includes a JWT token in the `Authorization: Bearer <JWT_TOKEN>` header. This request is received by the API Gateway, which is configured with a custom JWT authorizer. The authorizer function is triggered and extracts the JWT from the request. It then fetches the public keys from PingOne’s JWKS (JSON Web Key Set) endpoint to validate the token. During this process, the authorizer verifies the JWT’s signature, ensures it was issued by a trusted PingOne environment, and checks that the token has not expired. If everything is valid, the authorizer returns an IAM policy that either allows or denies access, along with any relevant user context. If access is granted, the request proceeds to the protected Lambda function behind the API. This Lambda uses the user context provided by the authorizer to generate a response. Finally, the API Gateway sends this response back to the client, including any necessary CORS headers to support web-based applications.

## Prerequisites

- AWS CLI configured with appropriate permissions
- AWS SAM CLI installed
- Python 3.9+ installed
- A PingOne account and environment

## Setup and Deployment

### Step 1: PingOne Configuration

1. **Log into PingOne Admin Console**
- Go to your PingOne admin portal
- Navigate to your environment

2. **Create OIDC Application**
- Go to Applications → Add Application
- Choose "OIDC Web App"
- Configure the following:
- **Name**: Simple JWT API
- **Redirect URIs**: `http://localhost:3000/callback`
- **Grant Types**: Authorization Code
- **Response Types**: Code
- **Scopes**: openid, profile, email

3. **Note Your Configuration**
- **Environment ID**: Found in the URL or environment settings
- **Client ID**: From your application settings
- **Client Secret**: From your application settings


### Step 2: Build & Deploy

1. **Build the Application**
```bash
sam build
```

2. **Deploy the Application**
```bash
sam deploy --guided
```

3. **Note the API Endpoint**
The deployment will output your API Gateway endpoint URL.

### Step 3: Generate Access Token

You can generate PingOne JWT tokens using multiple methods:

<details>
<summary><strong>Click to see various token generation methods</strong></summary>

#### Option 1: Using the provided script
```bash
# Set environment variables
export CLIENT_ID=your-client-id
export CLIENT_SECRET=your-client-secret
export ENVIRONMENT_ID=your-environment-id

# Get authorization URL
./get_token.sh

# Exchange auth code for token
./get_token.sh YOUR_AUTH_CODE
```

#### Option 2: PingOne Admin Console
- Log into your PingOne admin console
- Navigate to Applications → Your App → Configuration
- Use the "Test Connection" or token generation features

#### Option 3: Direct OAuth2 Flow
1. **Authorization URL:**
```
https://auth.pingone.com/YOUR_ENV_ID/as/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:3000/callback&scope=openid%20profile%20email
```

2. **Token Exchange (curl):**
```bash
curl -X POST "https://auth.pingone.com/YOUR_ENV_ID/as/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
-d "grant_type=authorization_code&code=YOUR_AUTH_CODE&redirect_uri=http://localhost:3000/callback"
```

#### Option 4: Postman/Insomnia
- Import PingOne OAuth2 collection
- Configure your environment variables
- Use the authorization code flow

</details>

### Step 4: Test the API

Once you have an access token from any method above:

```bash
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" YOUR_API_ENDPOINT
```

## API Endpoints

### GET /user
Returns user information after successful JWT verification.

**Response:**
```json
{
"success": true,
"message": "JWT verification successful - Connection established",
"user": "<user_id>",
"scope": "openid profile email",
"timestamp": 29999
}
```


## Project Structure

```
├── src/
│ ├── jwt_authorizer.py # Custom JWT authorizer function
│ ├── user_info.py # Protected API endpoint
│ └── requirements.txt # Python dependencies
├── template.yaml # SAM template
├── get_token.sh # Token generation helper (optional)
└── samconfig.toml.example # SAM configuration template
```

## Troubleshooting

- **Invalid Token**: Check that your PingOne configuration matches your deployment
- **CORS Issues**: Ensure your redirect URI is properly configured in PingOne
- **Deployment Errors**: Verify your AWS credentials and permissions
- **Token Expiration**: PingOne tokens have limited lifespans, generate new ones as needed
- **Script Issues**: If `get_token.sh` doesn't work, use alternative token generation methods listed above


88 changes: 88 additions & 0 deletions lambda-pingidentity-python/get_token.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/bin/bash

#!/bin/bash

# PingOne Configuration from environment variables
CLIENT_ID="${CLIENT_ID}"
CLIENT_SECRET="${CLIENT_SECRET}"
ENVIRONMENT_ID="${ENVIRONMENT_ID}"
REDIRECT_URI="${REDIRECT_URI:-http://localhost:3000/callback}"

# Validate required environment variables
if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ] || [ -z "$ENVIRONMENT_ID" ]; then
echo "❌ Error: Missing required environment variables!"
echo "Please set the following environment variables:"
echo "- CLIENT_ID"
echo "- CLIENT_SECRET"
echo "- ENVIRONMENT_ID"
echo ""
echo "Example:"
echo "export CLIENT_ID=your-client-id"
echo "export CLIENT_SECRET=your-client-secret"
echo "export ENVIRONMENT_ID=your-environment-id"
echo "export REDIRECT_URI=http://localhost:3000/callback # optional"
exit 1
fi

# Create base64 encoded credentials
CREDENTIALS=$(echo -n "${CLIENT_ID}:${CLIENT_SECRET}" | base64)

echo "🔐 PingOne Token via Curl"
echo "========================="
echo ""
echo "Your Configuration:"
echo "- Environment ID: ${ENVIRONMENT_ID}"
echo "- Client ID: ${CLIENT_ID}"
echo "- Redirect URI: ${REDIRECT_URI}"
echo ""

echo "Step 1: Get Authorization Code"
echo "============================="
echo "Open this URL in your browser:"
echo ""
echo "https://auth.pingone.com/${ENVIRONMENT_ID}/as/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&scope=openid%20profile%20email"
echo ""

if [ $# -eq 0 ]; then
echo "Usage: $0 <authorization_code>"
echo ""
echo "After getting the code from the browser, run:"
echo "$0 YOUR_AUTH_CODE"
exit 1
fi

AUTH_CODE=$1

echo "Step 2: Exchange Code for Token"
echo "==============================="
echo "Using authorization code: ${AUTH_CODE:0:20}..."
echo ""

# Exchange code for token
RESPONSE=$(curl --silent --location --request POST "https://auth.pingone.com/${ENVIRONMENT_ID}/as/token" \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header "Authorization: Basic ${CREDENTIALS}" \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=${AUTH_CODE}" \
--data-urlencode "redirect_uri=${REDIRECT_URI}")

echo "Response:"
echo "${RESPONSE}" | python3 -m json.tool 2>/dev/null || echo "${RESPONSE}"
echo ""

# Extract access token
ACCESS_TOKEN=$(echo "${RESPONSE}" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('access_token', 'NOT_FOUND'))" 2>/dev/null)

if [ "${ACCESS_TOKEN}" != "NOT_FOUND" ] && [ "${ACCESS_TOKEN}" != "" ]; then
echo "Success! Access Token Retrieved"
echo "================================="
echo ""
echo "Access Token:"
echo "${ACCESS_TOKEN}"
echo ""
echo "🧪 Test your API:"
echo "curl -H 'Authorization: Bearer ${ACCESS_TOKEN}' https://<API_GATEWAY_ID>.execute-api.<REGION>.amazonaws.com/prod/user"
else
echo "Failed to get access token"
echo "Check the authorization code and try again"
fi
107 changes: 107 additions & 0 deletions lambda-pingidentity-python/src/jwt_authorizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import json
import jwt
import os
from typing import Dict, Any
from jwt import PyJWKClient
from jwt.exceptions import InvalidTokenError, ExpiredSignatureError

def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
"""
PingOne JWT Authorizer Lambda function
Validates JWT tokens from PingOne and returns authorization policy
"""
print(f"PingOne Authorizer event: {json.dumps(event)}")

try:
# Extract token from Authorization header
token = extract_token(event)
if not token:
print("No token found")
return generate_policy('user', 'Deny', event['methodArn'])

# Validate JWT token with PingOne
payload = validate_pingone_jwt(token)
if not payload:
print("Invalid PingOne token")
return generate_policy('user', 'Deny', event['methodArn'])

user_id = payload.get('sub', 'unknown')
print(f"PingOne token validated for user: {user_id}")

# Generate allow policy with user context
policy = generate_policy(user_id, 'Allow', event['methodArn'])
policy['context'] = {
'userId': user_id,
'email': payload.get('email', ''),
'scope': payload.get('scope', ''),
'clientId': payload.get('client_id', ''),
'issuer': payload.get('iss', ''),
'tokenPayload': json.dumps(payload, default=str)
}

return policy

except Exception as e:
print(f"PingOne Authorizer error: {str(e)}")
return generate_policy('user', 'Deny', event['methodArn'])

def extract_token(event: Dict[str, Any]) -> str:
"""Extract JWT token from Authorization header"""
auth_header = event.get('authorizationToken', '')

if auth_header.startswith('Bearer '):
return auth_header[7:] # Remove 'Bearer ' prefix

return auth_header

def validate_pingone_jwt(token: str) -> Dict[str, Any]:
"""Validate JWT token with PingOne JWKS"""
try:
ping_issuer = os.environ.get('PING_ISSUER_URL')
ping_jwks_url = os.environ.get('PING_JWKS_URL')

if not ping_issuer or not ping_jwks_url:
print("PingOne configuration missing")
return None

# Get signing key from PingOne JWKS
jwks_client = PyJWKClient(ping_jwks_url)
signing_key = jwks_client.get_signing_key_from_jwt(token)

# Validate token
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=ping_issuer,
options={"verify_aud": False} # Skip audience validation for simplicity
)

print(f"PingOne JWT payload: {json.dumps(payload, default=str)}")
return payload

except ExpiredSignatureError:
print("PingOne token has expired")
return None
except InvalidTokenError as e:
print(f"Invalid PingOne token: {str(e)}")
return None
except Exception as e:
print(f"PingOne JWT validation error: {str(e)}")
return None

def generate_policy(principal_id: str, effect: str, resource: str) -> Dict[str, Any]:
"""Generate IAM policy for API Gateway"""
return {
'principalId': principal_id,
'policyDocument': {
'Version': '2012-10-17',
'Statement': [
{
'Action': 'execute-api:Invoke',
'Effect': effect,
'Resource': resource
}
]
}
}
3 changes: 3 additions & 0 deletions lambda-pingidentity-python/src/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
PyJWT==2.8.0
cryptography==41.0.7
requests==2.31.0
Loading