11{-# LANGUAGE OverloadedStrings #-}
22{-# LANGUAGE RecursiveDo #-}
33
4- module FakeGithubApi (Server , start , stop , clearOutput , getOutput , setTokenLifetime ) where
4+ module FakeGithubApi (Server , start , stop , clearOutput , getOutput , setTokenLifetime , setTokenExpirationOffset ) where
55
66import Universum
77
88import Network.Wai
99import qualified Network.Wai.Handler.Warp as Warp
10- import Network.HTTP.Types (status200 , status201 , status400 , status404 , methodPost , methodGet )
10+ import Network.HTTP.Types (status200 , status201 , status400 , status401 , status404 , methodPost , methodGet )
1111import Data.Aeson (encode , object , (.=) , Value )
1212import qualified Data.Aeson as Aeson
1313import qualified Data.Map.Strict as Map
14- import Data.Time.Clock (getCurrentTime , addUTCTime )
14+ import Data.Time.Clock (getCurrentTime , addUTCTime , UTCTime )
1515import Data.Time.Format.ISO8601 (iso8601Show )
16+ import qualified Data.ByteString as BS
1617
1718import Control.Concurrent (forkIO , ThreadId , killThread )
1819
@@ -35,43 +36,87 @@ handleAccessTokenRequest server instId req respond =
3536 then do
3637 -- Read token lifetime from server state
3738 lifetimeSeconds <- readIORef server. tokenLifetimeSeconds
39+ offset <- readIORef server. tokenExpirationOffset
3840 now <- getCurrentTime
39- let expiresAt = addUTCTime (fromIntegral lifetimeSeconds) now
41+ let actualExpiry = addUTCTime (fromIntegral lifetimeSeconds) now
42+ let reportedExpiry = addUTCTime (fromIntegral offset) actualExpiry
43+
44+ -- Issue unique token
45+ n <- atomicModifyIORef server. tokenCounter (\ c -> (c + 1 , c + 1 ))
46+ let tokenText = " mock-access-token-" <> show n
47+
48+ -- Track the token with its actual expiry
49+ modifyIORef server. validTokens (Map. insert tokenText actualExpiry)
50+
4051 addOutput server $ " Requested access token for installation " <> instId
4152 respond $ responseLBS status200 [(" Content-Type" , " application/json" )]
4253 (encode $ object
43- [ " token" .= ( " mock-access-token " :: Text )
44- , " expires_at" .= iso8601Show expiresAt
54+ [ " token" .= tokenText
55+ , " expires_at" .= iso8601Show reportedExpiry
4556 , " installation_id" .= instId
4657 ])
4758 else respond $ responseLBS status400 [] " Bad Request"
4859
60+ -- Validate the Bearer token from the Authorization header.
61+ -- Returns Nothing if valid, or a 401 response if invalid/expired.
62+ validateToken :: Server -> Request -> IO (Maybe Response )
63+ validateToken server req = do
64+ tokens <- readIORef server. validTokens
65+ -- If no tokens have been issued yet, skip validation (backwards compat)
66+ if Map. null tokens
67+ then pure Nothing
68+ else do
69+ now <- getCurrentTime
70+ let mAuth = fmap snd $ find (\ (k, _) -> k == " Authorization" ) (requestHeaders req)
71+ case mAuth of
72+ Just authHeader
73+ | Just tokenBS <- BS. stripPrefix " Bearer " authHeader -> do
74+ let tokenText = decodeUtf8 tokenBS
75+ case Map. lookup tokenText tokens of
76+ Just expiry
77+ | now < expiry -> pure Nothing -- Valid
78+ | otherwise -> pure $ Just $ responseLBS status401 [] " Token expired"
79+ Nothing -> pure $ Just $ responseLBS status401 [] " Unknown token"
80+ | otherwise -> pure $ Just $ responseLBS status401 [] " Invalid Authorization header"
81+ Nothing -> pure $ Just $ responseLBS status401 [] " Missing Authorization header"
82+
4983handleCommitStatusRequest :: Server -> Text -> Text -> Text -> Request -> (Response -> IO ResponseReceived ) -> IO ResponseReceived
5084handleCommitStatusRequest server owner repo commitSha req respond =
5185 if requestMethod req == methodPost
5286 then do
53- body <- strictRequestBody req
54- -- Store the status for later retrieval
55- storeStatus server commitSha body
56- -- Note: commit SHA omitted because it's nondeterministic
57- addOutput server $ " Updated commit status for " <> owner <> " /" <> repo <> " to " <> decodeUtf8 body
58- respond $ responseLBS status201 [(" Content-Type" , " application/json" )]
59- (encode $ object [" state" .= (" success" :: Text ), " sha" .= commitSha, " repository" .= repo, " owner" .= owner])
87+ mReject <- validateToken server req
88+ case mReject of
89+ Just rejection -> respond rejection
90+ Nothing -> do
91+ body <- strictRequestBody req
92+ -- Store the status for later retrieval
93+ storeStatus server commitSha body
94+ -- Note: commit SHA omitted because it's nondeterministic
95+ addOutput server $ " Updated commit status for " <> owner <> " /" <> repo <> " to " <> decodeUtf8 body
96+ respond $ responseLBS status201 [(" Content-Type" , " application/json" )]
97+ (encode $ object [" state" .= (" success" :: Text ), " sha" .= commitSha, " repository" .= repo, " owner" .= owner])
6098 else respond $ responseLBS status400 [] " Bad Request"
6199
62100handleGetCommitStatuses :: Server -> Text -> Text -> Text -> Request -> (Response -> IO ResponseReceived ) -> IO ResponseReceived
63101handleGetCommitStatuses server _owner _repo commitSha req respond =
64102 if requestMethod req == methodGet
65103 then do
66- statuses <- getStatuses server commitSha
67- respond $ responseLBS status200 [(" Content-Type" , " application/json" )] (encode statuses)
104+ mReject <- validateToken server req
105+ case mReject of
106+ Just rejection -> respond rejection
107+ Nothing -> do
108+ statuses <- getStatuses server commitSha
109+ respond $ responseLBS status200 [(" Content-Type" , " application/json" )] (encode statuses)
68110 else respond $ responseLBS status400 [] " Bad Request"
69111
70112data Server = Server
71113 { tid :: ThreadId
72114 , output :: IORef [Text ]
73115 , statuses :: IORef (Map Text [Value ]) -- Map from commit SHA to list of status objects
74116 , tokenLifetimeSeconds :: IORef Int
117+ , tokenCounter :: IORef Int
118+ , validTokens :: IORef (Map Text UTCTime ) -- Map from token to actual expiry time
119+ , tokenExpirationOffset :: IORef Int -- Seconds to add to reported expires_at (simulates clock skew)
75120 }
76121
77122start :: Int -> IO Server
@@ -80,9 +125,12 @@ start port = do
80125 output <- newIORef []
81126 statuses <- newIORef Map. empty
82127 tokenLifetimeSeconds <- newIORef 3600 -- Default: 1 hour
128+ tokenCounter <- newIORef 0
129+ validTokens <- newIORef Map. empty
130+ tokenExpirationOffset <- newIORef 0
83131 let settings = Warp. setPort port $ Warp. setBeforeMainLoop (putMVar started () ) Warp. defaultSettings
84132 rec
85- let server = Server {tid, output, statuses, tokenLifetimeSeconds}
133+ let server = Server {tid, output, statuses, tokenLifetimeSeconds, tokenCounter, validTokens, tokenExpirationOffset }
86134 tid <- forkIO $ Warp. runSettings settings $ app server
87135 takeMVar started
88136 pure server
@@ -94,10 +142,13 @@ addOutput :: Server -> Text -> IO ()
94142addOutput (Server {output}) msg = modifyIORef output (msg : )
95143
96144clearOutput :: Server -> IO ()
97- clearOutput (Server {output, statuses, tokenLifetimeSeconds}) = do
98- writeIORef output []
99- writeIORef statuses Map. empty
100- writeIORef tokenLifetimeSeconds 3600 -- Reset to default
145+ clearOutput server = do
146+ writeIORef server. output []
147+ writeIORef server. statuses Map. empty
148+ writeIORef server. tokenLifetimeSeconds 3600 -- Reset to default
149+ writeIORef server. tokenCounter 0
150+ writeIORef server. validTokens Map. empty
151+ writeIORef server. tokenExpirationOffset 0
101152
102153getOutput :: Server -> IO [Text ]
103154getOutput (Server {output}) = reverse <$> readIORef output
@@ -116,3 +167,6 @@ getStatuses (Server {statuses}) commitSha = do
116167
117168setTokenLifetime :: Server -> Int -> IO ()
118169setTokenLifetime server seconds = writeIORef server. tokenLifetimeSeconds seconds
170+
171+ setTokenExpirationOffset :: Server -> Int -> IO ()
172+ setTokenExpirationOffset server seconds = writeIORef server. tokenExpirationOffset seconds
0 commit comments