Auto-indexing service and GraphQL API for AT Protocol Records
at main 237 lines 10 kB view raw
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.