forked from
slices.network/quickslice
Auto-indexing service and GraphQL API for AT Protocol Records
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}