@@ -2,8 +2,261 @@ import { test, expect, inject } from 'vitest'
22import { z } from 'zod'
33
44const mcpServerPort = inject ( 'mcpServerPort' )
5- const mcpServerUrl = ` http://localhost:${ mcpServerPort } `
5+ const EPIC_ME_AUTH_SERVER_URL = ' http://localhost:7788'
66
7- test ( `TODO: update this test title to describe the important thing we're working on in this exercise step` , async ( ) => {
8- // TODO: implement this test
7+ // TypeScript interfaces for API responses
8+ interface AuthServerConfig {
9+ authorization_endpoint : string
10+ token_endpoint : string
11+ [ key : string ] : unknown
12+ }
13+
14+ interface ClientRegistration {
15+ client_id : string
16+ client_secret ?: string
17+ [ key : string ] : unknown
18+ }
19+
20+ interface AuthResult {
21+ redirectTo : string
22+ [ key : string ] : unknown
23+ }
24+
25+ interface TokenResult {
26+ access_token : string
27+ token_type : string
28+ [ key : string ] : unknown
29+ }
30+
31+ // Helper function to generate PKCE challenge
32+ function generateCodeChallenge ( ) {
33+ const codeVerifier = btoa (
34+ String . fromCharCode ( ...crypto . getRandomValues ( new Uint8Array ( 32 ) ) ) ,
35+ )
36+ . replace ( / \+ / g, '-' )
37+ . replace ( / \/ / g, '_' )
38+ . replace ( / = / g, '' )
39+
40+ return {
41+ codeVerifier,
42+ codeChallenge : codeVerifier , // For simplicity, using plain method
43+ codeChallengeMethod : 'plain' ,
44+ }
45+ }
46+
47+ test ( `{test title that describes the important thing we're working on in this exercise step}` , async ( ) => {
48+ const mcpServerUrl = `http://localhost:${ mcpServerPort } `
49+
50+ const unauthorizedResponse = await fetch ( `${ mcpServerUrl } /mcp` , {
51+ method : 'POST' ,
52+ headers : {
53+ 'content-type' : 'application/json' ,
54+ accept : 'application/json, text/event-stream' ,
55+ } ,
56+ body : JSON . stringify ( {
57+ jsonrpc : '2.0' ,
58+ id : 1 ,
59+ method : 'tools/list' ,
60+ } ) ,
61+ } )
62+
63+ expect (
64+ unauthorizedResponse . status ,
65+ '🚨 Expected 401 status for unauthorized request' ,
66+ ) . toBe ( 401 )
67+
68+ const wwwAuthHeader = unauthorizedResponse . headers . get ( 'WWW-Authenticate' )
69+ expect (
70+ wwwAuthHeader ,
71+ '🚨 WWW-Authenticate header should be present' ,
72+ ) . toBeTruthy ( )
73+ expect (
74+ wwwAuthHeader ,
75+ '🚨 WWW-Authenticate header should contain Bearer realm' ,
76+ ) . toContain ( 'Bearer realm="EpicMe"' )
77+
78+ // Extract the resource_metadata url from the WWW-Authenticate header
79+ const resourceMetadataUrl = wwwAuthHeader
80+ ?. split ( ',' )
81+ . find ( ( h ) => h . includes ( 'resource_metadata=' ) )
82+ ?. split ( '=' ) [ 1 ]
83+
84+ expect (
85+ resourceMetadataUrl ,
86+ '🚨 Resource metadata URL should be present in WWW-Authenticate header' ,
87+ ) . toBeTruthy ( )
88+
89+ const resourceMetadataResponse = await fetch ( resourceMetadataUrl ! )
90+ expect (
91+ resourceMetadataResponse . ok ,
92+ '🚨 fetching resource metadata should succeed' ,
93+ ) . toBe ( true )
94+
95+ const resourceMetadataResult = z
96+ . object ( {
97+ resource : z . string ( ) ,
98+ authorization_servers : z . array ( z . string ( ) ) . length ( 1 ) ,
99+ scopes_supported : z . array ( z . string ( ) ) ,
100+ } )
101+ . safeParse ( await resourceMetadataResponse . json ( ) )
102+ if ( ! resourceMetadataResult . success ) {
103+ throw new Error (
104+ '🚨 Invalid resource metadata: ' + resourceMetadataResult . error . message ,
105+ )
106+ }
107+ const resourceMetadata = resourceMetadataResult . data
108+
109+ const authorizationUrl = resourceMetadata . authorization_servers [ 0 ] !
110+
111+ // Step 1: Metadata discovery
112+ // Test OAuth Authorization Server discovery
113+ const authServerDiscoveryResponse = await fetch (
114+ `${ authorizationUrl } /.well-known/oauth-authorization-server` ,
115+ )
116+ expect (
117+ authServerDiscoveryResponse . ok ,
118+ '🚨 OAuth authorization server discovery should succeed' ,
119+ ) . toBe ( true )
120+
121+ const authServerConfig =
122+ ( await authServerDiscoveryResponse . json ( ) ) as AuthServerConfig
123+ expect (
124+ authServerConfig . authorization_endpoint ,
125+ '🚨 Authorization endpoint should be present in discovery' ,
126+ ) . toBeTruthy ( )
127+ expect (
128+ authServerConfig . token_endpoint ,
129+ '🚨 Token endpoint should be present in discovery' ,
130+ ) . toBeTruthy ( )
131+
132+ // Step 2: Dynamic client registration
133+ const clientRegistrationResponse = await fetch (
134+ `${ EPIC_ME_AUTH_SERVER_URL } /oauth/register` ,
135+ {
136+ method : 'POST' ,
137+ headers : {
138+ 'content-type' : 'application/json' ,
139+ accept : 'application/json, text/event-stream' ,
140+ } ,
141+ body : JSON . stringify ( {
142+ client_name : 'Test MCP Client' ,
143+ redirect_uris : [ `${ mcpServerUrl } /mcp` ] ,
144+ scope : 'read write' ,
145+ } ) ,
146+ } ,
147+ )
148+
149+ expect (
150+ clientRegistrationResponse . ok ,
151+ '🚨 Client registration should succeed' ,
152+ ) . toBe ( true )
153+ const clientRegistration =
154+ ( await clientRegistrationResponse . json ( ) ) as ClientRegistration
155+ expect (
156+ clientRegistration . client_id ,
157+ '🚨 Client ID should be returned from registration' ,
158+ ) . toBeTruthy ( )
159+
160+ // Step 3: Preparing Authorization (getting the auth URL)
161+ const { codeVerifier, codeChallenge, codeChallengeMethod } =
162+ generateCodeChallenge ( )
163+ const state = crypto . randomUUID ( )
164+ const redirectUri = `${ mcpServerUrl } /mcp`
165+
166+ // Step 4: Requesting the auth code programmatically
167+ const testAuthUrl = new URL ( `${ EPIC_ME_AUTH_SERVER_URL } /test-auth` )
168+ // Use the registered client ID instead of the one from the auth URL
169+ testAuthUrl . searchParams . set ( 'client_id' , clientRegistration . client_id )
170+ testAuthUrl . searchParams . set ( 'redirect_uri' , redirectUri )
171+ testAuthUrl . searchParams . set ( 'response_type' , 'code' )
172+ testAuthUrl . searchParams . set ( 'code_challenge' , codeChallenge )
173+ testAuthUrl . searchParams . set ( 'code_challenge_method' , codeChallengeMethod )
174+ testAuthUrl . searchParams . set ( 'scope' , 'read write' )
175+ testAuthUrl . searchParams . set ( 'state' , state )
176+
177+ const authCodeResponse = await fetch ( testAuthUrl . toString ( ) )
178+ expect ( authCodeResponse . ok , '🚨 Auth code request should succeed' ) . toBe ( true )
179+
180+ const authResult = ( await authCodeResponse . json ( ) ) as AuthResult
181+ expect (
182+ authResult . redirectTo ,
183+ '🚨 Redirect URL should be returned' ,
184+ ) . toBeTruthy ( )
185+
186+ // Step 5: Supplying the auth code (extract from redirect URL)
187+ const redirectUrl = new URL ( authResult . redirectTo )
188+ const authCode = redirectUrl . searchParams . get ( 'code' )
189+ const returnedState = redirectUrl . searchParams . get ( 'state' )
190+
191+ expect (
192+ authCode ,
193+ '🚨 Auth code should be present in redirect URL' ,
194+ ) . toBeTruthy ( )
195+ expect ( returnedState , '🚨 State should be returned' ) . toBe ( state )
196+
197+ // Step 6: Requesting the token
198+ const tokenParams = new URLSearchParams ( {
199+ grant_type : 'authorization_code' ,
200+ code : authCode ! ,
201+ redirect_uri : redirectUri ,
202+ client_id : clientRegistration . client_id , // Use registered client ID
203+ code_verifier : codeVerifier ,
204+ } )
205+
206+ // Add client_secret if provided during registration
207+ if ( clientRegistration . client_secret ) {
208+ tokenParams . set ( 'client_secret' , clientRegistration . client_secret )
209+ }
210+
211+ const tokenResponse = await fetch ( `${ EPIC_ME_AUTH_SERVER_URL } /oauth/token` , {
212+ method : 'POST' ,
213+ headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } ,
214+ body : tokenParams ,
215+ } )
216+
217+ if ( ! tokenResponse . ok ) {
218+ const errorText = await tokenResponse . text ( )
219+ console . error ( 'Token exchange failed:' , tokenResponse . status , errorText )
220+ }
221+
222+ expect ( tokenResponse . ok , '🚨 Token exchange should succeed' ) . toBe ( true )
223+ const tokenResult = ( await tokenResponse . json ( ) ) as TokenResult
224+ expect (
225+ tokenResult . access_token ,
226+ '🚨 Access token should be returned' ,
227+ ) . toBeTruthy ( )
228+ expect (
229+ tokenResult . token_type ?. toLowerCase ( ) ,
230+ '🚨 Token type should be Bearer' ,
231+ ) . toBe ( 'bearer' )
232+
233+ // Step 7: Performing authenticated requests (listing tools)
234+ // Verify the token works by making a simple authenticated request to the MCP server
235+ // We'll test that we get past the authentication (no 401) even if we get protocol errors
236+ const authTestResponse = await fetch ( `${ mcpServerUrl } /mcp` , {
237+ method : 'POST' ,
238+ headers : {
239+ 'Content-Type' : 'application/json' ,
240+ Accept : 'application/json, text/event-stream' ,
241+ Authorization : `Bearer ${ tokenResult . access_token } ` ,
242+ } ,
243+ body : JSON . stringify ( {
244+ jsonrpc : '2.0' ,
245+ id : 1 ,
246+ method : 'initialize' ,
247+ params : {
248+ protocolVersion : '2024-11-05' ,
249+ capabilities : { } ,
250+ clientInfo : {
251+ name : 'Test Client' ,
252+ version : '1.0.0' ,
253+ } ,
254+ } ,
255+ } ) ,
256+ } )
257+
258+ expect (
259+ authTestResponse . status ,
260+ '🚨 Should not get 401 Unauthorized with valid token' ,
261+ ) . not . toBe ( 401 )
9262} )
0 commit comments