Auto-indexing service and GraphQL API for AT Protocol Records
at main 159 lines 5.6 kB view raw
1/// DPoP validation middleware for protected resources 2import database/executor.{type Executor} 3import database/repositories/oauth_access_tokens 4import database/repositories/oauth_dpop_jti 5import gleam/http.{Delete, Get, Head, Options, Patch, Post, Put} 6import gleam/http/request 7import gleam/option.{None, Some} 8import gleam/string 9import lib/oauth/dpop/validator 10import lib/oauth/token_generator 11import wisp 12 13/// Validate DPoP-bound access token 14/// Returns the user_id if valid, or an error response 15pub fn validate_dpop_access( 16 req: wisp.Request, 17 db: Executor, 18 resource_url: String, 19) -> Result(String, wisp.Response) { 20 // Extract Authorization header 21 case request.get_header(req, "authorization") { 22 Error(_) -> Error(unauthorized("Missing Authorization header")) 23 Ok(header) -> { 24 // Parse "DPoP <token>" or "Bearer <token>" 25 case string.split(header, " ") { 26 ["DPoP", token] -> validate_dpop_token(req, db, token, resource_url) 27 ["Bearer", token] -> validate_bearer_token(db, token) 28 _ -> Error(unauthorized("Invalid Authorization header format")) 29 } 30 } 31 } 32} 33 34fn validate_dpop_token( 35 req: wisp.Request, 36 db: Executor, 37 token: String, 38 resource_url: String, 39) -> Result(String, wisp.Response) { 40 // Get DPoP proof from header 41 case validator.get_dpop_header(req.headers) { 42 None -> Error(unauthorized("Missing DPoP proof for DPoP-bound token")) 43 Some(dpop_proof) -> { 44 // Verify the DPoP proof 45 let method = method_to_string(req.method) 46 case validator.verify_dpop_proof(dpop_proof, method, resource_url, 300) { 47 Error(reason) -> Error(unauthorized("Invalid DPoP proof: " <> reason)) 48 Ok(dpop_result) -> { 49 // Check JTI for replay 50 case oauth_dpop_jti.use_jti(db, dpop_result.jti, dpop_result.iat) { 51 Error(_) -> Error(server_error("Database error")) 52 Ok(False) -> Error(unauthorized("DPoP proof replay detected")) 53 Ok(True) -> { 54 // Get the access token and verify JKT matches 55 case oauth_access_tokens.get(db, token) { 56 Error(_) -> Error(server_error("Database error")) 57 Ok(None) -> Error(unauthorized("Invalid access token")) 58 Ok(Some(access_token)) -> { 59 // Check if token is expired 60 case token_generator.is_expired(access_token.expires_at) { 61 True -> Error(unauthorized("Access token has expired")) 62 False -> { 63 // Check if token is revoked 64 case access_token.revoked { 65 True -> 66 Error(unauthorized("Access token has been revoked")) 67 False -> { 68 case access_token.dpop_jkt { 69 None -> 70 Error(unauthorized("Token is not DPoP-bound")) 71 Some(jkt) -> { 72 case jkt == dpop_result.jkt { 73 False -> 74 Error(unauthorized("DPoP key mismatch")) 75 True -> { 76 case access_token.user_id { 77 None -> 78 Error(unauthorized("Token has no user")) 79 Some(user_id) -> Ok(user_id) 80 } 81 } 82 } 83 } 84 } 85 } 86 } 87 } 88 } 89 } 90 } 91 } 92 } 93 } 94 } 95 } 96 } 97} 98 99fn validate_bearer_token( 100 db: Executor, 101 token: String, 102) -> Result(String, wisp.Response) { 103 case oauth_access_tokens.get(db, token) { 104 Error(_) -> Error(server_error("Database error")) 105 Ok(None) -> Error(unauthorized("Invalid access token")) 106 Ok(Some(access_token)) -> { 107 // Check if token is expired 108 case token_generator.is_expired(access_token.expires_at) { 109 True -> Error(unauthorized("Access token has expired")) 110 False -> { 111 // Check if token is revoked 112 case access_token.revoked { 113 True -> Error(unauthorized("Access token has been revoked")) 114 False -> { 115 // DPoP-bound tokens MUST use DPoP authorization 116 case access_token.dpop_jkt { 117 Some(_) -> 118 Error(unauthorized( 119 "DPoP-bound token requires DPoP authorization", 120 )) 121 None -> { 122 case access_token.user_id { 123 None -> Error(unauthorized("Token has no user")) 124 Some(user_id) -> Ok(user_id) 125 } 126 } 127 } 128 } 129 } 130 } 131 } 132 } 133 } 134} 135 136fn method_to_string(method: http.Method) -> String { 137 case method { 138 Get -> "GET" 139 Post -> "POST" 140 Put -> "PUT" 141 Delete -> "DELETE" 142 Patch -> "PATCH" 143 Head -> "HEAD" 144 Options -> "OPTIONS" 145 _ -> "GET" 146 } 147} 148 149fn unauthorized(message: String) -> wisp.Response { 150 wisp.response(401) 151 |> wisp.set_header("content-type", "application/json") 152 |> wisp.set_body(wisp.Text("{\"error\":\"" <> message <> "\"}")) 153} 154 155fn server_error(message: String) -> wisp.Response { 156 wisp.response(500) 157 |> wisp.set_header("content-type", "application/json") 158 |> wisp.set_body(wisp.Text("{\"error\":\"" <> message <> "\"}")) 159}