( token: string | undefined, )
| 34 | * - root clients get null projectId + organizationId (multi-project access) |
| 35 | */ |
| 36 | export async function authenticateToken( |
| 37 | token: string | undefined, |
| 38 | ): Promise<McpAuthContext> { |
| 39 | if (!token) { |
| 40 | throw new McpAuthError('Missing authentication token'); |
| 41 | } |
| 42 | |
| 43 | let decoded: string; |
| 44 | try { |
| 45 | decoded = Buffer.from(token, 'base64').toString('utf-8'); |
| 46 | } catch { |
| 47 | throw new McpAuthError('Invalid token encoding'); |
| 48 | } |
| 49 | |
| 50 | const colonIndex = decoded.indexOf(':'); |
| 51 | if (colonIndex === -1) { |
| 52 | logger.warn( |
| 53 | { decodedLength: decoded.length }, |
| 54 | 'MCP auth: token has no colon separator', |
| 55 | ); |
| 56 | throw new McpAuthError( |
| 57 | 'Invalid token format — expected base64(clientId:clientSecret)', |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | const clientId = decoded.slice(0, colonIndex); |
| 62 | const clientSecret = decoded.slice(colonIndex + 1); |
| 63 | |
| 64 | logger.info( |
| 65 | { clientId, secretPrefix: clientSecret.slice(0, 6) }, |
| 66 | 'MCP auth: decoded token', |
| 67 | ); |
| 68 | |
| 69 | if ( |
| 70 | !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( |
| 71 | clientId, |
| 72 | ) |
| 73 | ) { |
| 74 | logger.warn({ clientId }, 'MCP auth: invalid client ID format'); |
| 75 | throw new McpAuthError('Invalid client ID format'); |
| 76 | } |
| 77 | |
| 78 | if (!clientSecret) { |
| 79 | throw new McpAuthError('Client secret is required'); |
| 80 | } |
| 81 | |
| 82 | const client = await getClientByIdCached(clientId); |
| 83 | if (!client) { |
| 84 | logger.warn({ clientId }, 'MCP auth: client not found'); |
| 85 | throw new McpAuthError('Invalid credentials'); |
| 86 | } |
| 87 | |
| 88 | logger.info( |
| 89 | { clientId, type: client.type, hasSecret: !!client.secret }, |
| 90 | 'MCP auth: client found', |
| 91 | ); |
| 92 | |
| 93 | if (!client.secret) { |
no test coverage detected