From c5b61f88f39c7aea708bfeee66048ecb0bf0893d Mon Sep 17 00:00:00 2001 From: Luke Hagar Date: Thu, 12 Mar 2026 15:35:36 -0500 Subject: [PATCH 1/3] Add PKCE (RFC 7636) to mithrandir OAuth flow Protect the authorization code grant against interception attacks by generating a code_verifier/code_challenge pair (S256) during auth initiation and sending the verifier during token exchange. The verifier is stored in DynamoDB and never exposed in API responses. Co-Authored-By: Claude Opus 4.6 --- backend/src/mithrandir/README.md | 15 ++++++++++- backend/src/mithrandir/index.ts | 40 +++++++++++++++++++++++++---- backend/src/mithrandir/openapi.yaml | 9 +++++-- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/backend/src/mithrandir/README.md b/backend/src/mithrandir/README.md index 7826daa9525ef..3ddafed974551 100644 --- a/backend/src/mithrandir/README.md +++ b/backend/src/mithrandir/README.md @@ -300,4 +300,17 @@ Below is an example of how to use these endpoints to implement the complete auth - Always store your private key securely - Use HTTPS for all API calls - Refresh tokens before they expire -- Consider using a higher key size (3072 or 4096) for increased security \ No newline at end of file +- Consider using a higher key size (3072 or 4096) for increased security + +## PKCE (Proof Key for Code Exchange) + +This service implements [PKCE (RFC 7636)](https://datatracker.ietf.org/doc/html/rfc7636) to protect the OAuth 2.0 Authorization Code flow against authorization code interception attacks. PKCE binds the authorization request to the token exchange request via a cryptographic proof, ensuring that an intercepted authorization code cannot be exchanged by an attacker. + +### How it works + +PKCE is handled entirely within this service — **no changes are needed by API consumers**. + +1. **Auth initiation** (`POST /sailapps/auth`): The service generates a random `code_verifier` and computes a `code_challenge` using SHA-256 (S256 method). The challenge is included in the authorization URL, and the verifier is stored in DynamoDB alongside the session data. +2. **Token exchange** (`POST /sailapps/auth/code`): The stored `code_verifier` is retrieved from DynamoDB and sent to the OAuth server along with the authorization code. The OAuth server verifies that the verifier matches the challenge from step 1 before issuing tokens. + +The `code_verifier` is never exposed in API responses or logs. \ No newline at end of file diff --git a/backend/src/mithrandir/index.ts b/backend/src/mithrandir/index.ts index 0e910edc22912..596822bbb4f77 100644 --- a/backend/src/mithrandir/index.ts +++ b/backend/src/mithrandir/index.ts @@ -7,7 +7,7 @@ import { UpdateCommand, DeleteCommand, } from '@aws-sdk/lib-dynamodb'; -import {randomBytes, createCipheriv, createDecipheriv, publicEncrypt, privateDecrypt, constants, randomUUID, generateKeyPairSync} from 'crypto'; +import {randomBytes, createCipheriv, createDecipheriv, publicEncrypt, privateDecrypt, constants, randomUUID, generateKeyPairSync, createHash} from 'crypto'; import {Hono} from 'hono'; import {handle} from 'hono/aws-lambda'; import {HTTPException} from 'hono/http-exception'; @@ -51,6 +51,24 @@ const ddbClient = process.env.ENDPOINT_OVERRIDE const ddbDocClient = DynamoDBDocumentClient.from(ddbClient); +// PKCE helper functions (RFC 7636) +function generateCodeVerifier(): string { + return randomBytes(32) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +function generateCodeChallenge(verifier: string): string { + return createHash('sha256') + .update(verifier) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + // Helper functions async function validateApiUrl(apiBaseURL?: string, tenant?: string): Promise { if (!apiBaseURL && !tenant) { @@ -101,11 +119,11 @@ async function getAuthInfo(baseURL: string) { } } -async function storeAuthData(uuid: string, baseURL: string) { +async function storeAuthData(uuid: string, baseURL: string, codeVerifier: string) { try { // TTL 5 minutes const ttl = Math.floor(Date.now() / 1000) + 300; - const objectToPut = {id: uuid, baseURL, ttl}; + const objectToPut = {id: uuid, baseURL, codeVerifier, ttl}; await ddbDocClient.send( new PutCommand({TableName: validatedTableName, Item: objectToPut}), ); @@ -145,12 +163,14 @@ async function exchangeCodeForToken( baseURL: string, code: string, redirectUri: string, + codeVerifier: string, ) { const tokenExchangeURL = new URL(baseURL + `/oauth/token`); tokenExchangeURL.searchParams.set('grant_type', 'authorization_code'); tokenExchangeURL.searchParams.set('client_id', validatedClientId); tokenExchangeURL.searchParams.set('code', code); tokenExchangeURL.searchParams.set('redirect_uri', redirectUri); + tokenExchangeURL.searchParams.set('code_verifier', codeVerifier); const tokenExchangeResp = await fetch(tokenExchangeURL, { method: 'POST', @@ -358,7 +378,9 @@ app.post('/Prod/sailapps/auth', async (c) => { const authInfo = await getAuthInfo(baseURL); const uuid = randomUUID(); - const objectToPut = await storeAuthData(uuid, baseURL); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const objectToPut = await storeAuthData(uuid, baseURL, codeVerifier); const state = {id: uuid, publicKey}; const authURL = new URL(authInfo.authorizeEndpoint); @@ -370,10 +392,14 @@ app.post('/Prod/sailapps/auth', async (c) => { body.dev === true ? validatedDevRedirectUrl : validatedRedirectUrl, ); authURL.searchParams.set('state', btoa(JSON.stringify(state))); + authURL.searchParams.set('code_challenge', codeChallenge); + authURL.searchParams.set('code_challenge_method', 'S256'); return c.json({ authURL: authURL.toString(), - ...objectToPut, + id: objectToPut.id, + baseURL: objectToPut.baseURL, + ttl: objectToPut.ttl, }); }); @@ -400,11 +426,15 @@ app.post('/Prod/sailapps/auth/code', async (c) => { if (!tableData.baseURL) { throw new HTTPException(400, {message: 'Invalid stored data'}); } + if (!tableData.codeVerifier) { + throw new HTTPException(400, {message: 'Invalid stored data: missing PKCE verifier'}); + } const tokenData = await exchangeCodeForToken( tableData.baseURL, code, body?.dev === true ? validatedDevRedirectUrl : validatedRedirectUrl, + tableData.codeVerifier, ); const encryptedToken = encryptToken(tokenData, atob(publicKey)); diff --git a/backend/src/mithrandir/openapi.yaml b/backend/src/mithrandir/openapi.yaml index 874d570019aef..f4c418aea0248 100644 --- a/backend/src/mithrandir/openapi.yaml +++ b/backend/src/mithrandir/openapi.yaml @@ -36,7 +36,10 @@ paths: /sailapps/auth: post: summary: Initiate authentication - description: Starts the authentication flow with tenant info and public key + description: >- + Starts the authentication flow with tenant info and public key. + Internally generates a PKCE code challenge (S256) and includes it in the authorization URL. + The code verifier is stored server-side and used during the token exchange. operationId: initiateAuth tags: - Authentication @@ -63,7 +66,9 @@ paths: /sailapps/auth/code: post: summary: Exchange authorization code for token - description: Exchanges an OAuth authorization code for an access token + description: >- + Exchanges an OAuth authorization code for an access token. + Automatically includes the PKCE code verifier (stored during auth initiation) in the token exchange request. operationId: exchangeCode tags: - Authentication From 47446642617ee6581a2b29c3e331f51ed4b81bdf Mon Sep 17 00:00:00 2001 From: Luke Parke Date: Fri, 13 Mar 2026 13:46:43 -0500 Subject: [PATCH 2/3] Apply suggestion from @luke-hagar-sp --- backend/src/mithrandir/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/mithrandir/index.ts b/backend/src/mithrandir/index.ts index 596822bbb4f77..5759b63db5c38 100644 --- a/backend/src/mithrandir/index.ts +++ b/backend/src/mithrandir/index.ts @@ -66,7 +66,7 @@ function generateCodeChallenge(verifier: string): string { .digest('base64') .replace(/\+/g, '-') .replace(/\//g, '_') - .replace(/=/g, ''); + .replace(/=+$/, ''); } // Helper functions From 0ed38b919f27b6c1bcb3c96c9c287136870472b4 Mon Sep 17 00:00:00 2001 From: Luke Hagar Date: Mon, 16 Mar 2026 16:29:37 -0500 Subject: [PATCH 3/3] Refactor OAuth token exchange to use URLSearchParams for form data Updated the token exchange functions to utilize URLSearchParams for constructing the request body, improving readability and maintainability. Removed the redirectUri parameter from the exchangeCodeForToken function as it is no longer needed. --- backend/src/mithrandir/index.js | 468 ++++++++++++++++++++++++++++++++ backend/src/mithrandir/index.ts | 57 ++-- 2 files changed, 500 insertions(+), 25 deletions(-) create mode 100644 backend/src/mithrandir/index.js diff --git a/backend/src/mithrandir/index.js b/backend/src/mithrandir/index.js new file mode 100644 index 0000000000000..98b2807ce982e --- /dev/null +++ b/backend/src/mithrandir/index.js @@ -0,0 +1,468 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.handler = void 0; +// Create a DocumentClient that represents the query to add an item +const client_dynamodb_1 = require("@aws-sdk/client-dynamodb"); +const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb"); +const crypto_1 = require("crypto"); +const hono_1 = require("hono"); +const aws_lambda_1 = require("hono/aws-lambda"); +const http_exception_1 = require("hono/http-exception"); +// Environment variables +const clientId = process.env.OAUTH_CLIENT_ID; +const redirectUrl = process.env.OAUTH_REDIRECT_URL; +const devRedirectUrl = process.env.OAUTH_DEV_REDIRECT_URL; +const tableName = process.env.AUTH_TOKENS_TABLE; +// Validate required environment variables +const requiredEnvVars = { + OAUTH_CLIENT_ID: clientId, + OAUTH_REDIRECT_URL: redirectUrl, + OAUTH_DEV_REDIRECT_URL: devRedirectUrl, + AUTH_TOKENS_TABLE: tableName, +}; +const missingEnvVars = Object.entries(requiredEnvVars) + .filter(([_, value]) => !value) + .map(([key]) => key); +if (missingEnvVars.length > 0) { + throw new Error(`Missing required environment variables: ${missingEnvVars.join(', ')}`); +} +// After validation, we can safely assert these values exist +const validatedClientId = clientId; +const validatedRedirectUrl = redirectUrl; +const validatedDevRedirectUrl = devRedirectUrl; +const validatedTableName = tableName; +const app = new hono_1.Hono(); +// Initialize DynamoDB client +const ddbClient = process.env.ENDPOINT_OVERRIDE + ? new client_dynamodb_1.DynamoDBClient({ endpoint: process.env.ENDPOINT_OVERRIDE }) + : new client_dynamodb_1.DynamoDBClient({}); +const ddbDocClient = lib_dynamodb_1.DynamoDBDocumentClient.from(ddbClient); +// PKCE helper functions (RFC 7636) +function generateCodeVerifier() { + return (0, crypto_1.randomBytes)(32) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} +function generateCodeChallenge(verifier) { + return (0, crypto_1.createHash)('sha256') + .update(verifier) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} +// Helper functions +async function validateApiUrl(apiBaseURL, tenant) { + if (!apiBaseURL && !tenant) { + throw new http_exception_1.HTTPException(400, { + message: 'apiBaseURL or tenant must be provided', + }); + } + let apiURL; + if (apiBaseURL) { + apiURL = new URL(apiBaseURL); + if (!apiURL.hostname) { + throw new http_exception_1.HTTPException(400, { message: 'apiBaseURL is not a valid URL' }); + } + } + else { + apiURL = new URL(`https://${tenant}.api.identitynow.com`); + if (!apiURL.hostname) { + throw new http_exception_1.HTTPException(400, { message: 'tenant is not valid' }); + } + } + if (!apiURL.origin) { + throw new http_exception_1.HTTPException(400, { + message: 'apiBaseURL or tenant provided is invalid', + }); + } + return apiURL.origin; +} +async function getAuthInfo(baseURL) { + try { + const authInfoResp = await fetch(baseURL + `/oauth/info`); + if (!authInfoResp.ok) { + throw new Error('Error retrieving tenant info'); + } + const authInfo = await authInfoResp.json(); + if (!authInfo?.authorizeEndpoint) { + throw new Error('Error retrieving tenant info'); + } + return authInfo; + } + catch (err) { + throw new http_exception_1.HTTPException(400, { + message: 'Error retrieving tenant information', + }); + } +} +async function storeAuthData(uuid, baseURL, codeVerifier) { + try { + // TTL 5 minutes + const ttl = Math.floor(Date.now() / 1000) + 300; + const objectToPut = { id: uuid, baseURL, codeVerifier, ttl }; + await ddbDocClient.send(new lib_dynamodb_1.PutCommand({ TableName: validatedTableName, Item: objectToPut })); + return objectToPut; + } + catch (err) { + console.error('Error creating item:', err); + throw new http_exception_1.HTTPException(400, { message: 'Error creating UUID' }); + } +} +async function getStoredData(uuid) { + try { + const data = await ddbDocClient.send(new lib_dynamodb_1.GetCommand({ TableName: validatedTableName, Key: { id: uuid } })); + if (!data.Item) { + throw new http_exception_1.HTTPException(400, { message: 'Invalid UUID' }); + } + return data.Item; + } + catch (err) { + console.error('Error retrieving item:', err); + throw new http_exception_1.HTTPException(400, { message: 'Error retrieving data' }); + } +} +async function deleteStoredData(uuid) { + try { + await ddbDocClient.send(new lib_dynamodb_1.DeleteCommand({ TableName: validatedTableName, Key: { id: uuid } })); + } + catch (err) { + console.error('Error deleting item:', err); + } +} +async function exchangeCodeForToken(baseURL, code, codeVerifier) { + const tokenUrl = baseURL + `/oauth/token`; + const formData = new URLSearchParams(); + formData.set('grant_type', 'authorization_code'); + formData.set('client_id', validatedClientId); + formData.set('code', code); + formData.set('code_verifier', codeVerifier); + const tokenResp = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString(), + }); + if (!tokenResp.ok) { + console.error('Token request failed:', await tokenResp.text()); + throw new http_exception_1.HTTPException(400, { message: 'Error exchanging code for token' }); + } + const tokenData = await tokenResp.json(); + if (!tokenData.access_token) { + throw new http_exception_1.HTTPException(400, { message: 'Invalid token response' }); + } + return tokenData; +} +async function exchangeRefreshToken(baseURL, refreshToken) { + const tokenUrl = baseURL + `/oauth/token`; + const formData = new URLSearchParams(); + formData.set('grant_type', 'refresh_token'); + formData.set('client_id', validatedClientId); + formData.set('refresh_token', refreshToken); + const tokenResp = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString(), + }); + if (!tokenResp.ok) { + console.error('Refresh token request failed:', await tokenResp.text()); + throw new http_exception_1.HTTPException(400, { message: 'Error exchanging refresh token' }); + } + const tokenData = await tokenResp.json(); + if (!tokenData.access_token) { + throw new http_exception_1.HTTPException(400, { message: 'Invalid token response' }); + } + return tokenData; +} +function encryptToken(tokenData, publicKey) { + const tokenString = JSON.stringify(tokenData); + const symmetricKey = (0, crypto_1.randomBytes)(32); // 256 bits + // Encrypt the data with AES-GCM + const iv = (0, crypto_1.randomBytes)(12); + const cipher = (0, crypto_1.createCipheriv)('aes-256-gcm', symmetricKey, iv); + let encryptedData = cipher.update(tokenString, 'utf8', 'base64'); + encryptedData += cipher.final('base64'); + const authTag = cipher.getAuthTag().toString('base64'); + // Encrypt the symmetric key with RSA + const encryptedSymmetricKey = (0, crypto_1.publicEncrypt)({ + key: publicKey, + padding: crypto_1.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, symmetricKey); + const result = { + version: '1.0', + algorithm: { + symmetric: 'AES-256-GCM', + asymmetric: 'RSA-OAEP-SHA256' + }, + data: { + ciphertext: encryptedData, + encryptedKey: encryptedSymmetricKey.toString('base64'), + iv: iv.toString('base64'), + authTag: authTag + } + }; + return JSON.stringify(result); +} +function generateRsaKeyPair(modulusLength = 2048) { + try { + // Generate the key pair + const { publicKey, privateKey } = (0, crypto_1.generateKeyPairSync)('rsa', { + modulusLength, // Key size in bits + publicKeyEncoding: { + type: 'spki', // SubjectPublicKeyInfo + format: 'pem' // PEM format + }, + privateKeyEncoding: { + type: 'pkcs8', // Private key in PKCS#8 format + format: 'pem' // PEM format + } + }); + return { + publicKey, + privateKey, + // Also return base64 encoded versions for ease of use + publicKeyBase64: btoa(publicKey), + privateKeyBase64: btoa(privateKey), + algorithm: 'RSA', + modulusLength, + format: { + public: 'spki/pem', + private: 'pkcs8/pem' + } + }; + } + catch (error) { + console.error('Key pair generation failed:', error); + throw new Error('Failed to generate key pair'); + } +} +function decryptToken(encryptedTokenData, privateKey) { + try { + const tokenData = JSON.parse(encryptedTokenData); + // Check token format version + if (tokenData.version !== '1.0') { + throw new Error('Unsupported token format version'); + } + // Verify the encryption algorithms used + if (tokenData.algorithm?.symmetric !== 'AES-256-GCM' || + tokenData.algorithm?.asymmetric !== 'RSA-OAEP-SHA256') { + throw new Error('Unsupported encryption algorithm'); + } + // Extract required data + const { ciphertext, encryptedKey, iv, authTag } = tokenData.data; + if (!ciphertext || !encryptedKey || !iv || !authTag) { + throw new Error('Invalid encrypted token format'); + } + // Decrypt the symmetric key with the private key + const encryptedSymmetricKey = Buffer.from(encryptedKey, 'base64'); + const symmetricKey = (0, crypto_1.privateDecrypt)({ + key: privateKey, + padding: crypto_1.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, encryptedSymmetricKey); + // Decrypt the data with the symmetric key + const decipher = (0, crypto_1.createDecipheriv)('aes-256-gcm', symmetricKey, Buffer.from(iv, 'base64')); + decipher.setAuthTag(Buffer.from(authTag, 'base64')); + let decryptedData = decipher.update(ciphertext, 'base64', 'utf8'); + decryptedData += decipher.final('utf8'); + return JSON.parse(decryptedData); + } + catch (error) { + console.error('Token decryption failed:', error); + throw new Error('Failed to decrypt token'); + } +} +async function storeEncryptedToken(uuid, encryptedToken) { + try { + // TTL 5 minutes + const ttl = Math.floor(Date.now() / 1000) + 300; + await ddbDocClient.send(new lib_dynamodb_1.UpdateCommand({ + TableName: validatedTableName, + Key: { id: uuid }, + UpdateExpression: 'set tokenInfo = :tokenInfo, #ttl = :ttl', + ExpressionAttributeNames: { + '#ttl': 'ttl', + }, + ExpressionAttributeValues: { + ':tokenInfo': encryptedToken, + ':ttl': ttl, + }, + })); + } + catch (err) { + console.error('Error updating item:', err); + throw new http_exception_1.HTTPException(400, { message: 'Error storing token' }); + } +} +// Retrieve a UUID, generate a random encryption key, and return the auth URL +app.post('/Prod/sailapps/auth', async (c) => { + if (c.req.header('Content-Type') !== 'application/json') { + throw new http_exception_1.HTTPException(400, { + message: 'Content-Type must be application/json', + }); + } + const body = await c.req.json(); + const baseURL = await validateApiUrl(body.apiBaseURL, body.tenant); + const publicKey = body.publicKey; + const authInfo = await getAuthInfo(baseURL); + const uuid = (0, crypto_1.randomUUID)(); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const objectToPut = await storeAuthData(uuid, baseURL, codeVerifier); + const state = { id: uuid, publicKey }; + const authURL = new URL(authInfo.authorizeEndpoint); + authURL.searchParams.set('client_id', validatedClientId); + authURL.searchParams.set('response_type', 'code'); + authURL.searchParams.set('redirect_uri', body.dev === true ? validatedDevRedirectUrl : validatedRedirectUrl); + authURL.searchParams.set('state', btoa(JSON.stringify(state))); + authURL.searchParams.set('code_challenge', codeChallenge); + authURL.searchParams.set('code_challenge_method', 'S256'); + return c.json({ + authURL: authURL.toString(), + id: objectToPut.id, + baseURL: objectToPut.baseURL, + ttl: objectToPut.ttl, + }); +}); +// Exchange the code for a token +app.post('/Prod/sailapps/auth/code', async (c) => { + let body; + if (c.req.raw.body) { + body = await c.req.json(); + } + const code = body?.code; + const state = body?.state; + if (!code) { + throw new http_exception_1.HTTPException(400, { message: 'Code not provided' }); + } + if (!state) { + throw new http_exception_1.HTTPException(400, { message: 'State not provided' }); + } + const { id: uuid, publicKey } = JSON.parse(atob(state)); + const tableData = await getStoredData(uuid); + if (!tableData.baseURL) { + throw new http_exception_1.HTTPException(400, { message: 'Invalid stored data' }); + } + if (!tableData.codeVerifier) { + throw new http_exception_1.HTTPException(400, { message: 'Invalid stored data: missing PKCE verifier' }); + } + const tokenData = await exchangeCodeForToken(tableData.baseURL, code, tableData.codeVerifier); + const encryptedToken = encryptToken(tokenData, atob(publicKey)); + await storeEncryptedToken(uuid, encryptedToken); + return c.json({ message: 'Token added successfully' }, 200); +}); +// Retrieve stored token +app.get('/Prod/sailapps/auth/token/:uuid', async (c) => { + const uuid = c.req.param('uuid'); + if (!uuid) { + throw new http_exception_1.HTTPException(400, { message: 'UUID not provided' }); + } + const data = await getStoredData(uuid); + if (!data.tokenInfo) { + throw new http_exception_1.HTTPException(400, { message: 'Token not found' }); + } + await deleteStoredData(uuid); + return c.json(data, 200); +}); +// Refresh token endpoint +app.post('/Prod/sailapps/auth/refresh', async (c) => { + if (c.req.header('Content-Type') !== 'application/json') { + throw new http_exception_1.HTTPException(400, { + message: 'Content-Type must be application/json', + }); + } + const body = await c.req.json(); + if (!body.refreshToken) { + throw new http_exception_1.HTTPException(400, { message: 'refreshToken is required' }); + } + const baseURL = await validateApiUrl(body.apiBaseURL, body.tenant); + try { + const tokenData = await exchangeRefreshToken(baseURL, body.refreshToken); + return c.json(tokenData, 200); + } + catch (error) { + if (error instanceof http_exception_1.HTTPException) { + throw error; + } + throw new http_exception_1.HTTPException(500, { message: 'Internal server error' }); + } +}); +// Decrypt token endpoint +app.post('/Prod/sailapps/auth/token/decrypt', async (c) => { + if (c.req.header('Content-Type') !== 'application/json') { + throw new http_exception_1.HTTPException(400, { + message: 'Content-Type must be application/json', + }); + } + const body = await c.req.json(); + // Validate required fields + if (!body.privateKey) { + throw new http_exception_1.HTTPException(400, { message: 'privateKey is required' }); + } + if (!body.encryptedToken) { + throw new http_exception_1.HTTPException(400, { message: 'encryptedToken is required' }); + } + try { + // If UUID is provided, get the encrypted token from the database + let tokenInfo; + if (body.uuid) { + const data = await getStoredData(body.uuid); + if (!data.tokenInfo) { + throw new http_exception_1.HTTPException(400, { message: 'Token not found for the provided UUID' }); + } + tokenInfo = data.tokenInfo; + } + else { + // Otherwise use the provided encrypted token directly + tokenInfo = body.encryptedToken; + } + // Decode the private key if it's base64-encoded + const privateKey = body.isBase64Encoded ? + atob(body.privateKey) : body.privateKey; + // Decrypt the token + const decryptedToken = decryptToken(tokenInfo, privateKey); + return c.json({ + token: decryptedToken, + tokenInfo: { + expiresAt: decryptedToken.expires_in ? + new Date(Date.now() + decryptedToken.expires_in * 1000).toISOString() : + undefined, + tokenType: decryptedToken.token_type || 'Bearer' + } + }, 200); + } + catch (error) { + console.error('Token decryption error:', error); + if (error instanceof http_exception_1.HTTPException) { + throw error; + } + throw new http_exception_1.HTTPException(400, { message: 'Failed to decrypt token' }); + } +}); +// Generate RSA key pair endpoint +app.post('/Prod/sailapps/auth/keypair', async (c) => { + try { + // Parse body if it exists + let keySize = 2048; // Default key size + // Generate the key pair + const keyPair = generateRsaKeyPair(keySize); + return c.json({ + message: `Successfully generated ${keySize}-bit RSA key pair`, + ...keyPair + }, 200); + } + catch (error) { + console.error('Key pair generation error:', error); + if (error instanceof http_exception_1.HTTPException) { + throw error; + } + throw new http_exception_1.HTTPException(500, { message: 'Failed to generate key pair' }); + } +}); +exports.handler = (0, aws_lambda_1.handle)(app); diff --git a/backend/src/mithrandir/index.ts b/backend/src/mithrandir/index.ts index 5759b63db5c38..88ee880050d37 100644 --- a/backend/src/mithrandir/index.ts +++ b/backend/src/mithrandir/index.ts @@ -162,59 +162,67 @@ async function deleteStoredData(uuid: string) { async function exchangeCodeForToken( baseURL: string, code: string, - redirectUri: string, codeVerifier: string, ) { - const tokenExchangeURL = new URL(baseURL + `/oauth/token`); - tokenExchangeURL.searchParams.set('grant_type', 'authorization_code'); - tokenExchangeURL.searchParams.set('client_id', validatedClientId); - tokenExchangeURL.searchParams.set('code', code); - tokenExchangeURL.searchParams.set('redirect_uri', redirectUri); - tokenExchangeURL.searchParams.set('code_verifier', codeVerifier); - - const tokenExchangeResp = await fetch(tokenExchangeURL, { + const tokenUrl = baseURL + `/oauth/token`; + const formData = new URLSearchParams(); + formData.set('grant_type', 'authorization_code'); + formData.set('client_id', validatedClientId); + formData.set('code', code); + formData.set('code_verifier', codeVerifier); + + const tokenResp = await fetch(tokenUrl, { method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString(), }); - if (!tokenExchangeResp.ok) { - console.error('Token exchange failed:', await tokenExchangeResp.text()); + if (!tokenResp.ok) { + console.error('Token request failed:', await tokenResp.text()); throw new HTTPException(400, {message: 'Error exchanging code for token'}); } - const tokenExchangeData = await tokenExchangeResp.json(); + const tokenData = await tokenResp.json(); - if (!tokenExchangeData.access_token) { + if (!tokenData.access_token) { throw new HTTPException(400, {message: 'Invalid token response'}); } - return tokenExchangeData; + return tokenData; } async function exchangeRefreshToken( baseURL: string, refreshToken: string, ) { - const tokenExchangeURL = new URL(baseURL + `/oauth/token`); - tokenExchangeURL.searchParams.set('grant_type', 'refresh_token'); - tokenExchangeURL.searchParams.set('client_id', validatedClientId); - tokenExchangeURL.searchParams.set('refresh_token', refreshToken); + const tokenUrl = baseURL + `/oauth/token`; + const formData = new URLSearchParams(); + formData.set('grant_type', 'refresh_token'); + formData.set('client_id', validatedClientId); + formData.set('refresh_token', refreshToken); - const tokenExchangeResp = await fetch(tokenExchangeURL, { + const tokenResp = await fetch(tokenUrl, { method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString(), }); - if (!tokenExchangeResp.ok) { - console.error('Refresh token exchange failed:', await tokenExchangeResp.text()); + if (!tokenResp.ok) { + console.error('Refresh token request failed:', await tokenResp.text()); throw new HTTPException(400, {message: 'Error exchanging refresh token'}); } - const tokenExchangeData = await tokenExchangeResp.json(); + const tokenData = await tokenResp.json(); - if (!tokenExchangeData.access_token) { + if (!tokenData.access_token) { throw new HTTPException(400, {message: 'Invalid token response'}); } - return tokenExchangeData; + return tokenData; } function encryptToken(tokenData: any, publicKey: string) { @@ -433,7 +441,6 @@ app.post('/Prod/sailapps/auth/code', async (c) => { const tokenData = await exchangeCodeForToken( tableData.baseURL, code, - body?.dev === true ? validatedDevRedirectUrl : validatedRedirectUrl, tableData.codeVerifier, );