forked from
slices.network/quickslice
Auto-indexing service and GraphQL API for AT Protocol Records
1-module(jose_ffi).
2-export([generate_dpop_proof/5, sha256_hash/1, compute_jwk_thumbprint/1, sha256_base64url/1, verify_dpop_proof/4]).
3
4%% Generate a DPoP proof JWT token
5%% Args: Method (binary), URL (binary), AccessToken (binary), JWKJson (binary), ServerNonce (binary)
6%% Returns: {ok, DPoPToken} | {error, Reason}
7generate_dpop_proof(Method, URL, AccessToken, JWKJson, ServerNonce) ->
8 try
9 %% Decode JSON - OTP 27+ has json module built-in
10 JWKMap = json:decode(JWKJson),
11 JWK = jose_jwk:from_map(JWKMap),
12
13 %% Get current timestamp
14 Now = erlang:system_time(second),
15
16 %% Generate a unique jti (different from the server nonce)
17 Jti = base64:encode(crypto:strong_rand_bytes(16)),
18
19 %% Create the DPoP header - include the public JWK
20 {_, PublicJWK} = jose_jwk:to_public_map(JWK),
21
22 %% Create the base DPoP payload (without ath)
23 BasePayload = #{
24 <<"jti">> => Jti,
25 <<"htm">> => Method,
26 <<"htu">> => URL,
27 <<"iat">> => Now
28 },
29
30 %% Add ath only if access token is provided (not for token exchange)
31 Payload1 = case AccessToken of
32 <<>> -> BasePayload;
33 _ -> maps:put(<<"ath">>, sha256_base64url(AccessToken), BasePayload)
34 end,
35
36 %% Add nonce field if ServerNonce is not empty
37 Payload = case ServerNonce of
38 <<>> -> Payload1;
39 _ -> maps:put(<<"nonce">>, ServerNonce, Payload1)
40 end,
41
42 %% Sign the JWT using jose compact API
43 Alg = detect_algorithm(JWK),
44
45 %% Create JWS header with custom fields
46 JWSHeader = #{
47 <<"alg">> => Alg,
48 <<"typ">> => <<"dpop+jwt">>,
49 <<"jwk">> => PublicJWK
50 },
51
52 %% Create JWT and JWS structs
53 JWT = jose_jwt:from_map(Payload),
54 JWS = jose_jws:from_map(JWSHeader),
55
56 %% Sign the JWT
57 Signed = jose_jwt:sign(JWK, JWS, JWT),
58
59 %% Compact to get the token string
60 {_JWS, CompactToken} = jose_jws:compact(Signed),
61
62 {ok, CompactToken}
63 catch
64 error:Reason ->
65 {error, {dpop_generation_failed, Reason}};
66 _:Error ->
67 {error, {dpop_generation_failed, Error}}
68 end.
69
70%% Hash a string using SHA-256 and return base64 encoded result
71sha256_hash(Data) when is_binary(Data) ->
72 Hash = crypto:hash(sha256, Data),
73 base64:encode(Hash);
74sha256_hash(Data) when is_list(Data) ->
75 sha256_hash(list_to_binary(Data)).
76
77%% Internal: Hash and base64url encode for "ath" claim
78sha256_base64url(Data) ->
79 Hash = crypto:hash(sha256, Data),
80 base64url_encode(Hash).
81
82%% Internal: Base64 URL-safe encoding (no padding)
83base64url_encode(Bin) ->
84 %% Standard base64 encode, then make URL-safe
85 Base64 = base64:encode(Bin),
86 %% Replace + with -, / with _, and remove padding =
87 NoPlus = binary:replace(Base64, <<"+">>, <<"-">>, [global]),
88 NoSlash = binary:replace(NoPlus, <<"/">>, <<"_">>, [global]),
89 binary:replace(NoSlash, <<"=">>, <<"">>, [global]).
90
91%% Internal: Detect algorithm from JWK
92detect_algorithm(JWK) ->
93 case jose_jwk:to_map(JWK) of
94 {_Kind, #{<<"kty">> := <<"EC">>}} ->
95 <<"ES256">>;
96 {_Kind, #{<<"kty">> := <<"RSA">>}} ->
97 <<"RS256">>;
98 {_Kind, #{<<"kty">> := <<"OKP">>}} ->
99 <<"EdDSA">>;
100 _ ->
101 <<"ES256">> %% Default to ES256
102 end.
103
104%% Compute JWK thumbprint (SHA-256 hash of the canonical JWK)
105compute_jwk_thumbprint(JWKJson) when is_binary(JWKJson) ->
106 try
107 case catch json:decode(JWKJson) of
108 {'EXIT', Reason} ->
109 {error, iolist_to_binary([<<"Invalid JWK JSON: ">>,
110 io_lib:format("~p", [Reason])])};
111 JWKMap when is_map(JWKMap) ->
112 JWK = jose_jwk:from_map(JWKMap),
113 {_Module, Thumbprint} = jose_jwk:thumbprint(JWK),
114 {ok, Thumbprint};
115 Other ->
116 {error, iolist_to_binary([<<"JWK decode returned unexpected type: ">>,
117 io_lib:format("~p", [Other])])}
118 end
119 catch
120 error:ErrorReason:ErrorStack ->
121 {error, iolist_to_binary([<<"JWK thumbprint error: ">>,
122 io_lib:format("~p at ~p", [ErrorReason, ErrorStack])])};
123 _:OtherError ->
124 {error, iolist_to_binary([<<"Unexpected error: ">>,
125 io_lib:format("~p", [OtherError])])}
126 end;
127compute_jwk_thumbprint(JWKJson) when is_list(JWKJson) ->
128 compute_jwk_thumbprint(list_to_binary(JWKJson)).
129
130%% Verify a DPoP proof JWT
131%% Args: DPoPProof (binary), ExpectedMethod (binary), ExpectedUrl (binary), MaxAgeSeconds (integer)
132%% Returns: {ok, #{jkt => Thumbprint, jti => Jti, iat => Iat}} | {error, Reason}
133verify_dpop_proof(DPoPProof, ExpectedMethod, ExpectedUrl, MaxAgeSeconds) ->
134 try
135 %% Split JWT into parts (compact serialization: header.payload.signature)
136 case binary:split(DPoPProof, <<".">>, [global]) of
137 [HeaderB64, _PayloadB64, _SignatureB64] ->
138 %% Decode the header (base64url)
139 HeaderJson = base64:decode(HeaderB64, #{mode => urlsafe, padding => false}),
140 HeaderMap = json:decode(HeaderJson),
141
142 %% Extract the JWK from the header
143 case maps:get(<<"jwk">>, HeaderMap, undefined) of
144 undefined ->
145 {error, <<"Missing jwk in DPoP header">>};
146 JWKMap ->
147 %% Verify typ is dpop+jwt
148 case maps:get(<<"typ">>, HeaderMap, undefined) of
149 <<"dpop+jwt">> ->
150 %% Reconstruct JWK for verification
151 %% jose_jwk:from_map expects base64url encoding natively
152 JWK = jose_jwk:from_map(JWKMap),
153
154 %% Verify the signature using jose
155 case jose_jwt:verify(JWK, DPoPProof) of
156 {true, JWT, _JWS} ->
157 Claims = jose_jwt:to_map(JWT),
158 validate_dpop_claims(Claims, JWK, ExpectedMethod, ExpectedUrl, MaxAgeSeconds);
159 {false, _, _} ->
160 {error, <<"Invalid DPoP signature">>}
161 end;
162 Other ->
163 {error, iolist_to_binary([<<"Invalid typ: expected dpop+jwt, got ">>,
164 io_lib:format("~p", [Other])])}
165 end
166 end;
167 _ ->
168 {error, <<"Invalid JWT format">>}
169 end
170 catch
171 error:Reason:Stacktrace ->
172 io:format("[DPoP] Error: ~p~nStacktrace: ~p~n", [Reason, Stacktrace]),
173 {error, iolist_to_binary([<<"DPoP verification failed: ">>,
174 io_lib:format("~p", [Reason])])};
175 _:Error ->
176 {error, iolist_to_binary([<<"DPoP verification error: ">>,
177 io_lib:format("~p", [Error])])}
178 end.
179
180%% Internal: Validate DPoP claims
181validate_dpop_claims({_Kind, Claims}, JWK, ExpectedMethod, ExpectedUrl, MaxAgeSeconds) ->
182 Now = erlang:system_time(second),
183
184 %% Extract required claims
185 Htm = maps:get(<<"htm">>, Claims, undefined),
186 Htu = maps:get(<<"htu">>, Claims, undefined),
187 Jti = maps:get(<<"jti">>, Claims, undefined),
188 Iat = maps:get(<<"iat">>, Claims, undefined),
189
190 %% Validate all required claims exist
191 case {Htm, Htu, Jti, Iat} of
192 {undefined, _, _, _} -> {error, <<"Missing htm claim">>};
193 {_, undefined, _, _} -> {error, <<"Missing htu claim">>};
194 {_, _, undefined, _} -> {error, <<"Missing jti claim">>};
195 {_, _, _, undefined} -> {error, <<"Missing iat claim">>};
196 _ ->
197 %% Validate htm matches
198 case Htm =:= ExpectedMethod of
199 false ->
200 {error, iolist_to_binary([<<"htm mismatch: expected ">>, ExpectedMethod,
201 <<", got ">>, Htm])};
202 true ->
203 %% Validate htu matches (normalize URLs)
204 case normalize_url(Htu) =:= normalize_url(ExpectedUrl) of
205 false ->
206 {error, iolist_to_binary([<<"htu mismatch: expected ">>, ExpectedUrl,
207 <<", got ">>, Htu])};
208 true ->
209 %% Validate iat is within acceptable range
210 case abs(Now - Iat) =< MaxAgeSeconds of
211 false ->
212 {error, <<"iat outside acceptable time window">>};
213 true ->
214 %% Compute JKT (SHA-256 thumbprint of the JWK)
215 Thumbprint = jose_jwk:thumbprint(JWK),
216 {ok, #{
217 jkt => Thumbprint,
218 jti => Jti,
219 iat => Iat
220 }}
221 end
222 end
223 end
224 end.
225
226%% Internal: Normalize URL for comparison (remove trailing slash, fragments)
227normalize_url(Url) when is_binary(Url) ->
228 %% Remove fragment
229 case binary:split(Url, <<"#">>) of
230 [Base | _] ->
231 %% Remove trailing slash
232 case byte_size(Base) > 0 andalso binary:last(Base) of
233 $/ -> binary:part(Base, 0, byte_size(Base) - 1);
234 _ -> Base
235 end;
236 _ -> Url
237 end.