diff --git a/lambda-pingidentity-python/ArchitectureDiagram.jpg b/lambda-pingidentity-python/ArchitectureDiagram.jpg new file mode 100644 index 000000000..5195418b5 Binary files /dev/null and b/lambda-pingidentity-python/ArchitectureDiagram.jpg differ diff --git a/lambda-pingidentity-python/LICENSE b/lambda-pingidentity-python/LICENSE new file mode 100644 index 000000000..96aa4d110 --- /dev/null +++ b/lambda-pingidentity-python/LICENSE @@ -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. diff --git a/lambda-pingidentity-python/README.md b/lambda-pingidentity-python/README.md new file mode 100644 index 000000000..d61961bc0 --- /dev/null +++ b/lambda-pingidentity-python/README.md @@ -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 ` 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: + +
+Click to see various token generation methods + +#### 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 + +
+ +### 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": "", + "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 + + diff --git a/lambda-pingidentity-python/get_token.sh b/lambda-pingidentity-python/get_token.sh new file mode 100755 index 000000000..d2a5f2f05 --- /dev/null +++ b/lambda-pingidentity-python/get_token.sh @@ -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 " + 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://.execute-api..amazonaws.com/prod/user" +else + echo "Failed to get access token" + echo "Check the authorization code and try again" +fi diff --git a/lambda-pingidentity-python/src/jwt_authorizer.py b/lambda-pingidentity-python/src/jwt_authorizer.py new file mode 100644 index 000000000..f217ae7c8 --- /dev/null +++ b/lambda-pingidentity-python/src/jwt_authorizer.py @@ -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 + } + ] + } + } diff --git a/lambda-pingidentity-python/src/requirements.txt b/lambda-pingidentity-python/src/requirements.txt new file mode 100644 index 000000000..ae3039d66 --- /dev/null +++ b/lambda-pingidentity-python/src/requirements.txt @@ -0,0 +1,3 @@ +PyJWT==2.8.0 +cryptography==41.0.7 +requests==2.31.0 diff --git a/lambda-pingidentity-python/src/user_info.py b/lambda-pingidentity-python/src/user_info.py new file mode 100644 index 000000000..0bc1fd2be --- /dev/null +++ b/lambda-pingidentity-python/src/user_info.py @@ -0,0 +1,48 @@ +import json +from typing import Dict, Any + +def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + try: + # Extract user context from authorizer (JWT already verified) + request_context = event.get('requestContext', {}) + authorizer = request_context.get('authorizer', {}) + + # Get basic user info from JWT + user_id = authorizer.get('userId', 'unknown') + scope = authorizer.get('scope', '') + + # Simple success response + response = { + 'statusCode': 200, + 'headers': { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + }, + 'body': json.dumps({ + 'success': True, + 'message': 'JWT verification successful - Connection established', + 'user': user_id, + 'scope': scope, + 'timestamp': context.get_remaining_time_in_millis() + }) + } + + # Print successful connection + print(f" JWT verification successful - User {user_id} connected successfully") + return response + + except Exception as e: + print(f" Error: {str(e)}") + + return { + 'statusCode': 500, + 'headers': { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + }, + 'body': json.dumps({ + 'success': False, + 'message': 'JWT verification failed - Connection error', + 'error': str(e) + }) + } diff --git a/lambda-pingidentity-python/template.yaml b/lambda-pingidentity-python/template.yaml new file mode 100644 index 000000000..f097cbe85 --- /dev/null +++ b/lambda-pingidentity-python/template.yaml @@ -0,0 +1,66 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Simple JWT API with custom authorizer + +Parameters: + PingOneIssuer: + Type: String + Description: PingOne Issuer URL + Default: "https://auth.pingone.com//as" + + PingOneJwksUrl: + Type: String + Description: PingOne JWKS URL for token validation + Default: "https://auth.pingone.com//as/jwks" + +Globals: + Function: + Timeout: 30 + MemorySize: 256 + Runtime: python3.9 + +Resources: + # API Gateway + SimpleJwtApi: + Type: AWS::Serverless::Api + Properties: + StageName: prod + Cors: + AllowMethods: "'GET,POST,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization'" + AllowOrigin: "'*'" + Auth: + DefaultAuthorizer: JwtAuthorizer + Authorizers: + JwtAuthorizer: + FunctionArn: !GetAtt JwtAuthorizerFunction.Arn + + # JWT Authorizer Function + JwtAuthorizerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: src/ + Handler: jwt_authorizer.lambda_handler + Environment: + Variables: + PING_ISSUER_URL: !Ref PingOneIssuer + PING_JWKS_URL: !Ref PingOneJwksUrl + + # User Info Handler Function + UserInfoFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: src/ + Handler: user_info.lambda_handler + Events: + GetUserInfo: + Type: Api + Properties: + RestApiId: !Ref SimpleJwtApi + Path: /user + Method: get + +Outputs: + UserInfoEndpoint: + Description: User info endpoint + Value: !Sub "https://${SimpleJwtApi}.execute-api.${AWS::Region}.amazonaws.com/prod/user"