Skip to content

Support for OIDC Token Exchange flow #17

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 13 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
4 changes: 4 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Octokit } from "@octokit/core";
import express from "express";
import { Readable } from "node:stream";
import { handleTokenExchange } from "./modules/exchangeController.js";

const app = express()

Expand Down Expand Up @@ -51,6 +52,9 @@ app.post("/", express.json(), async (req, res) => {
Readable.from(copilotLLMResponse.body).pipe(res);
})

// Endpoint to handle token exchange requests (OIDC token exchange)
app.post('/exchange', express.urlencoded({ extended: true }), handleTokenExchange);

const port = Number(process.env.PORT || '3000')
app.listen(port, () => {
console.log(`Server running on port ${port}`)
Expand Down
54 changes: 54 additions & 0 deletions modules/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# OIDC Support Modules
>*GitHub Docs Reference: [Using OIDC with GitHub Copilot Extensions](https://docs.github.com/en/copilot/building-copilot-extensions/using-oidc-with-github-copilot-extensions)*

This folder contains key OIDC support modules used in the **Blackbeard Extension** project. Below is an overview of the primary files and their functionality:

## Files Overview

### 1. `oidcHelper.js`
This module provides helper functions for handling OpenID Connect (OIDC) token validation and signing key retrieval. It is designed to work with GitHub's OIDC tokens.

#### Key Functions:
- **`getSigningKey(header)`**
Retrieves the public signing key from GitHub's JWKS URI to validate tokens.
- *Parameters:*
- `header`: The JWT header containing the `kid` (key ID).
- *Returns:*
- The public signing key for verifying JWTs.

- **`isValidJWT(payload)`**
Validates the payload of a decoded JWT against a set of rules, including `aud` (audience), `sub` (subject), `iat` (issued at), and more.
- *Parameters:*
- `payload`: Decoded JWT payload.
- *Returns:*
- `true` if the JWT payload is valid. Throws an error otherwise.

#### Dependencies:
- `jwks-rsa`: For fetching signing keys from GitHub's JWKS URI.
- `dotenv`: For loading environment variables.

---

### 2. `exchangeController.js`
This module implements the main logic for handling token exchange requests using the OAuth 2.0 Token Exchange flow. It validates incoming requests and generates new tokens based on the provided `subject_token`.

#### Key Function:
- **`handleTokenExchange(req, res)`**
Handles token exchange requests at the `/exchange` endpoint.
- *Parameters:*
- `req`: Express request object containing the token exchange payload.
- `res`: Express response object for sending the response.
- *Process:*
1. Validates `grant_type` and `subject_token_type`.
2. Verifies the `subject_token` using `oidcHelper.js` methods.
3. Generates a new access token (stub logic included for customization).
4. Returns the new token in the response.

#### Response Example:
```json
{
"access_token": "newly_exchanged_token_value", // Replace with actual generated token
"Issued_token_type":"urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 120
}
93 changes: 93 additions & 0 deletions modules/exchangeController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import jwt from 'jsonwebtoken';
import { getSigningKey, isValidJWT } from './oidcHelper.js';
import { logger } from './logger.js';

/**
* @description Handles the token exchange request.
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
*/
export async function handleTokenExchange(req, res) {

logger.info("Received token exchange request at endpoint '/exchange' (OIDC flow)");

try {
// Log the request headers
logger.debug("Request Headers '/exchange': " + JSON.stringify(req.headers, null, 2));

// Parse the request body for token exchange parameters
const { subject_token, subject_token_type, grant_type } = req.body;

logger.debug("Validating token exchange request payload requirements");

// Validate the grant type
logger.debug("Validating grant_type: " + grant_type);
if (grant_type !== "urn:ietf:params:oauth:grant-type:token-exchange") {
logger.error("Unsupported grant type: " + grant_type);
return res.status(400).json({
error: "unsupported_grant_type",
error_description: "Only token exchange is supported",
});
}

// Validate the subject token type
logger.debug("Validating subject_token_type: " + subject_token_type);
if (subject_token_type !== "urn:ietf:params:oauth:token-type:id_token") {
logger.error("Unsupported subject_token_type: " + subject_token_type);
return res.status(400).json({
error: "unsupported_token_type",
error_description: "Only access tokens are supported as subject_token_type",
});
}

if (!subject_token || !subject_token_type || !grant_type) {
logger.error("Invalid token exchange request payload: " + req.body);
return res.status(400).json({ error: "invalid_request", error_description: "Missing required parameters" });
}

logger.info("Fetching signing key for token verification ('https://github.com/login/oauth/.well-known/jwks.json')");
const signingKey = await getSigningKey(req.headers);

logger.info("Verifying token signature, using signing key");
logger.debug("Token to verify: \n" + subject_token);
try {
const payload = jwt.verify(subject_token, signingKey, {
algorithms: ["RS256"], // Specify the algorithm used to sign the token
});
logger.debug("Token Payload: " + JSON.stringify(payload, null, 2));

await isValidJWT(payload);
logger.info("JWT is valid");

} catch (error) {
logger.error("JWT verification failed: " + error.message);
return res.status(400).json({ "error": "invalid_request" });
}

// -------------------------------------------------
// Insert your access_token generation logic here
// -------------------------------------------------

// const exchangedToken = {
// access_token: "newly_exchanged_token_value", // Replace with actual token generation logic
// token_type: "Bearer",
// expires_in: 120, // Token expiration time in seconds
// scope: "read write", // Adjust the scope as needed
// };
const exchangedToken = {
"access_token": "newly_exchanged_token_value", // Replace with actual generated token
"Issued_token_type":"urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 120
}

logger.debug("Exchanged Token: " + JSON.stringify(exchangedToken));
logger.info("Token exchange successful");

// Respond with the exchanged token
return res.status(200).json(exchangedToken);
} catch (error) {
logger.error("Internal server error during token exchange: " + error);
return res.status(500).json({ "error": "invalid_request" });
}
}
25 changes: 25 additions & 0 deletions modules/logger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import pino from "pino";

// Create a pino logger instance
const logger = pino({
level: process.env.LOG_LEVEL || "info", // Use LOG_LEVEL from environment or default to "info"

transport: {
targets: [
{
target: "pino-pretty",
options: { colorize: true },
},
],
},
});


// Helper function to format debug output
const formatDebug = (label, value) => {
const labelWidth = 30; // Adjust the width as needed
return label.padEnd(labelWidth, ' ') + ": " + value;
};

// Export the logger and the formatDebug function
export { logger, formatDebug };
93 changes: 93 additions & 0 deletions modules/oidcHelper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import jwksClient from 'jwks-rsa';
import dotenv from 'dotenv';
import { logger, formatDebug } from './logger.js';

// Load environment variables from .env file
dotenv.config();

// JWKS client setup for GitHub OIDC tokens
const jwks = jwksClient({
jwksUri: 'https://github.com/login/oauth/.well-known/jwks.json'
});

/**
* @description Helper function to retrieve signing key
* @param {*} header
* @returns
*/
async function getSigningKey(header) {
return new Promise((resolve, reject) => {
jwks.getSigningKey(header.kid, (err, key) => {
if (err) {
logger.error('Error fetching signing key:', err);
return reject(err);
}
const signingKey = key.getPublicKey();
logger.debug("Successfully fetched signing key: \n" + signingKey); // Debugging: Log the signing key
resolve(signingKey);
});
});
}

/**
* @description Validates a JWT payload for required fields.
* @param {Object} payload - The decoded JWT payload.
* @returns {boolean} - Returns true if the JWT is valid, otherwise throws an error.
*/
async function isValidJWT(payload) {
logger.info("Validating JWT payload");

// Optional: Retrieve the validation data from the environment
const GITHUB_APP_CLIENT_ID = process.env.GITHUB_APP_CLIENT_ID || null;
const ACTOR = "https://api.githubcopilot.com";

const now = Math.floor(Date.now() / 1000); // Current timestamp in seconds

// Validate audience (aud)
logger.debug(formatDebug("aud (Copilot App client id)", payload.aud));
// only validate the client id if it is set
if (GITHUB_APP_CLIENT_ID) {
if (payload.aud !== GITHUB_APP_CLIENT_ID) {
throw new Error(`Invalid audience (aud). Expected: ${GITHUB_APP_CLIENT_ID}, Received: ${payload.aud}`);
}
}

// Validate subject (sub)
logger.debug(formatDebug("sub (Requester GitHub user id)", payload.sub));
if (!payload.sub || typeof payload.sub !== "string") {
throw new Error("Invalid subject (sub). It must be a non-empty string.");
}

// Validate issued at (iat)
logger.debug(formatDebug("iat (issued at)", payload.iat));
if (!payload.iat || typeof payload.iat !== "number" || payload.iat > now) {
// Debug statement for iat validation
const iatDate = new Date(payload.iat * 1000).toISOString(); // Convert iat to human-readable format
const nowDate = new Date(now * 1000).toISOString(); // Convert current timestamp to human-readable format
logger.debug(`\t iat validation: payload.iat=${iatDate}, now=${nowDate}, isFuture=${payload.iat > now}`);
throw new Error("Invalid issued at (iat). It must be a timestamp in the past.");
}

// Validate not before (nbf)
logger.debug(formatDebug("nbf (not before)", payload.nbf));
if (!payload.nbf || typeof payload.nbf !== "number" || payload.nbf > now) {
throw new Error("Invalid not before (nbf). It must be a timestamp in the past.");
}

// Validate expiration time (exp)
logger.debug(formatDebug("exp (expiration time)", payload.exp));
if (!payload.exp || typeof payload.exp !== "number" || payload.exp <= now) {
throw new Error("Invalid expiration time (exp). It must be a timestamp in the future.");
}

// Validate actor (act)
logger.debug(formatDebug("act (identify token purpose)", JSON.stringify(payload.act)));
if (!payload.act || payload.act.sub !== ACTOR) {
throw new Error("Invalid actor (act). Expected: "+ ACTOR +", Received: " + payload.act.sub);
}

return true; // All validations passed
}

// Export the functions
export { getSigningKey, isValidJWT };
Loading