├── spec ├── spec_helper.cr ├── fixtures │ ├── pubkey.pem │ ├── jwk-pubkey.json │ └── private.pem ├── integration │ ├── claims │ │ ├── jti_spec.cr │ │ ├── exp_spec.cr │ │ ├── nbf_spec.cr │ │ ├── iss_spec.cr │ │ ├── sub_spec.cr │ │ ├── aud_spec.cr │ │ └── typ_spec.cr │ ├── algorithms │ │ ├── none_spec.cr │ │ ├── hmac_spec.cr │ │ ├── eddsa_spec.cr │ │ ├── es256k_spec.cr │ │ ├── rsa_spec.cr │ │ ├── ecdsa_spec.cr │ │ ├── ps256_decode_spec.cr │ │ ├── eddsa_decode_spec.cr │ │ ├── es256k_decode_spec.cr │ │ └── rsa_pss_spec.cr │ └── jwks_spec.cr └── jwt_spec.cr ├── src ├── jwt │ ├── version.cr │ ├── jwks │ │ ├── oidc_metadata.cr │ │ └── jwk.cr │ ├── errors.cr │ └── jwks.cr └── jwt.cr ├── examples ├── iat_claim.cr ├── jti_claim.cr ├── basic_usage.cr ├── sub_claim.cr ├── nbf_claim.cr ├── aud_claim.cr ├── iss_claim.cr ├── exp_claim.cr ├── pss_and_eddsa_usage.cr ├── es256k_usage.cr └── jwks_entra_example.cr ├── .ameba.yml ├── .gitignore ├── shard.yml ├── CHANGELOG.md ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── README.md └── JWKS_README.md /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "webmock" 3 | require "../src/jwt" 4 | require "../src/jwt/jwks" 5 | -------------------------------------------------------------------------------- /src/jwt/version.cr: -------------------------------------------------------------------------------- 1 | module JWT 2 | {% begin %} 3 | VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify.downcase }} 4 | {% end %} 5 | end 6 | -------------------------------------------------------------------------------- /examples/iat_claim.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | 3 | # Create token with iat claim: 4 | payload = {"foo" => "bar", "iat" => Time.utc.to_unix} 5 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 6 | -------------------------------------------------------------------------------- /.ameba.yml: -------------------------------------------------------------------------------- 1 | Lint/UselessAssign: 2 | Excluded: 3 | - examples/* 4 | 5 | Lint/DebugCalls: 6 | Excluded: 7 | - examples/* 8 | 9 | Metrics/CyclomaticComplexity: 10 | Excluded: 11 | - src/jwt/jwks.cr 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /lib/ 3 | /libs/ 4 | /.crystal/ 5 | /.shards/ 6 | /bin/ 7 | 8 | # Libraries don't need dependency lock 9 | # Dependencies will be locked in application that uses them 10 | /shard.lock 11 | *.DS_Store 12 | -------------------------------------------------------------------------------- /examples/jti_claim.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | require "secure_random" 3 | 4 | jti = SecureRandom.urlsafe_base64 5 | payload = {"foo" => "bar", "jti" => jti} 6 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 7 | pp token 8 | -------------------------------------------------------------------------------- /examples/basic_usage.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | 3 | # Encoding 4 | payload = {"foo" => "bar"} 5 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 6 | pp token 7 | 8 | # Decoding 9 | payload, header = JWT.decode(token, "SecretKey", JWT::Algorithm::HS256) 10 | pp payload 11 | pp header 12 | -------------------------------------------------------------------------------- /examples/sub_claim.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | 3 | payload = {"nomo" => "Sergeo", "sub" => "Esperanto"} 4 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 5 | 6 | # Raises JWT::InvalidSubjectError, because "sub" claim does not match 7 | JWT.decode(token, "key", JWT::Algorithm::HS256, sub: "Junularo") 8 | -------------------------------------------------------------------------------- /examples/nbf_claim.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | 3 | # Create token that will become acceptable in 1 minute 4 | nbf = Time.utc.to_unix + 60 5 | payload = {"foo" => "bar", "nbf" => nbf} 6 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 7 | 8 | # Currently it's not acceptable, raises JWT::ImmatureSignatureError 9 | JWT.decode(token, "SecretKey", JWT::Algorithm::HS256) 10 | -------------------------------------------------------------------------------- /examples/aud_claim.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | 3 | payload = {"foo" => "bar", "aud" => ["sergey", "julia"]} 4 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 5 | 6 | # OK, aud matches 7 | payload, header = JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "sergey") 8 | 9 | # aud does not match, raises JWT::InvalidAudienceError 10 | payload, header = JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "max") 11 | -------------------------------------------------------------------------------- /examples/iss_claim.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | 3 | payload = {"foo" => "bar", "iss" => "me"} 4 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 5 | 6 | # OK, because iss matches 7 | payload, header = JWT.decode(token, "SecretKey", JWT::Algorithm::HS256, iss: "me") 8 | 9 | # iss does not match, raises JWT::InvalidIssuerError 10 | payload, header = JWT.decode(token, "SecretKey", JWT::Algorithm::HS256, iss: "you") 11 | -------------------------------------------------------------------------------- /examples/exp_claim.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | 3 | # Create token that expires in 1 minute 4 | exp = Time.utc.to_unix + 60 5 | payload = {"foo" => "bar", "exp" => exp} 6 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 7 | 8 | # Can be decoded 9 | payload, header = JWT.decode(token, "SecretKey", JWT::Algorithm::HS256) 10 | 11 | sleep 61 12 | # Already is expired, raises JWT::ExpiredSignatureError 13 | payload, header = JWT.decode(token, "SecretKey", JWT::Algorithm::HS256) 14 | -------------------------------------------------------------------------------- /spec/fixtures/pubkey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4TGylSSgNd/iRNRwxYeF 3 | 2/ONR23ozpZj6L3zZTXWP47bf6rxGYC9vmw6eIkpvmBPLNQ5C7oVTJJcNvngwYg/ 4 | VnExPo3YL4PYi5yF2Ribk5jj1RgxVbOET7uOGmTwHde11h9TvP/XvLfoXbMFEXTj 5 | +SPD2irqn99X8oNhdxPC6CpO28J6s/IJSmRnPqHK9SJvyPdK2Qz7sig06tFcftQq 6 | qrpbLH/VZkiEZtcf/OKcFog/FuYWJCDTSqqKG5No1tBaYJ3vRgiF1yUCgz4ekPTx 7 | iF2DjpQexU7psDZljakKbb/rIj6nqfI8CDdemR7A/WtkryCiqelKZkOlrbDYmC2E 8 | xwIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /spec/fixtures/jwk-pubkey.json: -------------------------------------------------------------------------------- 1 | { 2 | "keys": [ 3 | { 4 | "kty": "RSA", 5 | "e": "AQAB", 6 | "kid": "bed7f3ea-d9c0-4e91-8455-d65604a4b030", 7 | "n": "4TGylSSgNd_iRNRwxYeF2_ONR23ozpZj6L3zZTXWP47bf6rxGYC9vmw6eIkpvmBPLNQ5C7oVTJJcNvngwYg_VnExPo3YL4PYi5yF2Ribk5jj1RgxVbOET7uOGmTwHde11h9TvP_XvLfoXbMFEXTj-SPD2irqn99X8oNhdxPC6CpO28J6s_IJSmRnPqHK9SJvyPdK2Qz7sig06tFcftQqqrpbLH_VZkiEZtcf_OKcFog_FuYWJCDTSqqKG5No1tBaYJ3vRgiF1yUCgz4ekPTxiF2DjpQexU7psDZljakKbb_rIj6nqfI8CDdemR7A_WtkryCiqelKZkOlrbDYmC2Exw" 8 | } 9 | ] 10 | } -------------------------------------------------------------------------------- /spec/integration/claims/jti_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe "jti claim" do 4 | context "token does not contain jti" do 5 | context ":jti option is passed to .decode" do 6 | it "raises InvalidJtiError" do 7 | end 8 | end 9 | end 10 | 11 | context "token contains jti" do 12 | context ":jti option is passed to .decode" do 13 | end 14 | 15 | context ":jti is not passed to .decode" do 16 | context "jti matches" do 17 | end 18 | 19 | context "jti does not match" do 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: jwt 2 | version: 1.7.1 3 | 4 | authors: 5 | - Potapov Sergey 6 | - Stephen von Takach 7 | 8 | dependencies: 9 | openssl_ext: 10 | github: spider-gazelle/openssl_ext 11 | version: ~> 2.6 12 | 13 | bindata: 14 | github: spider-gazelle/bindata 15 | version: ~> 2.1 16 | 17 | ed25519: 18 | github: spider-gazelle/ed25519 19 | version: ~> 1.1 20 | 21 | development_dependencies: 22 | ameba: 23 | github: veelenga/ameba 24 | 25 | webmock: 26 | github: manastech/webmock.cr 27 | branch: master 28 | 29 | license: MIT 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | #### v1.1.0 2018-06-17 2 | 3 | * Adds RSA token support 4 | * Supports specifying custom headers 5 | * Supports skipping verification and validation 6 | * BREAKING: uses enum to select JWT Algorithm 7 | 8 | #### v0.3.0 2018-06-17 9 | 10 | * Compatibility with Crystal 0.25 11 | 12 | #### v0.2.3 2018-05-28 13 | 14 | * Joined crystal-community org 15 | 16 | #### v0.2.2 2016-12-12 17 | 18 | * Compatibility with Crystal 0.20 (alanwillms) 19 | 20 | #### v0.2.1 2016-11-21 21 | 22 | * Compatibility with Crystal 0.19 (akwiatkowski) 23 | 24 | #### v0.2.0 2016-05-18 25 | 26 | * Migrate to Crystal 0.17.0 27 | * `JWT.decode` returns Tuple instead of Array 28 | 29 | #### v0.1.1 2016-01-29 30 | 31 | * Support of all JWT claims 32 | 33 | #### v0.1.0 2016-01-24 34 | 35 | * First public release 36 | -------------------------------------------------------------------------------- /spec/integration/claims/exp_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe "exp claim" do 4 | context "exp is in the future" do 5 | it "token is accepted" do 6 | exp = Time.utc.to_unix + 10 7 | payload = {"exp" => exp} 8 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 9 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 10 | payload.should eq({"exp" => exp}) 11 | end 12 | end 13 | 14 | context "exp is in the past" do 15 | it "raises VerificationError" do 16 | exp = Time.utc.to_unix - 1 17 | payload = {"exp" => exp} 18 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 19 | expect_raises(JWT::ExpiredSignatureError, "Signature is expired") do 20 | JWT.decode(token, "key", JWT::Algorithm::HS256) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /src/jwt/jwks/oidc_metadata.cr: -------------------------------------------------------------------------------- 1 | module JWT 2 | class JWKS 3 | # OIDC metadata structure from /.well-known/openid-configuration 4 | struct OIDCMetadata 5 | include JSON::Serializable 6 | 7 | property issuer : String 8 | property jwks_uri : String 9 | property authorization_endpoint : String? 10 | property token_endpoint : String? 11 | property userinfo_endpoint : String? 12 | property end_session_endpoint : String? 13 | 14 | @[JSON::Field(key: "response_types_supported")] 15 | property response_types_supported : Array(String)? 16 | 17 | @[JSON::Field(key: "subject_types_supported")] 18 | property subject_types_supported : Array(String)? 19 | 20 | @[JSON::Field(key: "id_token_signing_alg_values_supported")] 21 | property id_token_signing_alg_values_supported : Array(String)? 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/integration/algorithms/none_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe "none algorithm" do 4 | alg = JWT::Algorithm::None 5 | secret_key = "$ecretKey" 6 | payload = {"foo" => "bar"} 7 | expected_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJmb28iOiJiYXIifQ." 8 | 9 | it "generates proper token, that can be decoded" do 10 | token = JWT.encode(payload, secret_key, alg) 11 | token.should eq(expected_token) 12 | 13 | decoded_token = JWT.decode(token, secret_key, alg) 14 | decoded_token[0].should eq(payload) 15 | decoded_token[1].should eq({"typ" => "JWT", "alg" => "none"}) 16 | end 17 | 18 | context "when token contains not 3 segments" do 19 | it "raises JWT::DecodeError" do 20 | ["e30", "e30.e30", "e30.e30.e30.e30"].each do |invalid_token| 21 | expect_raises(JWT::DecodeError) do 22 | JWT.decode(invalid_token, secret_key, alg) 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Potapov Sergey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/jwt/errors.cr: -------------------------------------------------------------------------------- 1 | module JWT 2 | # Basic JWT exception. 3 | class Error < ::Exception; end 4 | 5 | # Is raised on attempt to use unsupported algorithm. 6 | class UnsupportedAlgorithmError < Error; end 7 | 8 | # raised when failed to decode token 9 | class DecodeError < Error; end 10 | 11 | # Is raised when failed to verify signature. 12 | class VerificationError < DecodeError; end 13 | 14 | # Is raised when signature is expired (see `exp` reserved claim name) 15 | class ExpiredSignatureError < DecodeError; end 16 | 17 | # Is raised when time hasn't reached nbf claim in the token. 18 | class ImmatureSignatureError < DecodeError; end 19 | 20 | # Is raised when 'aud' does not match. 21 | class InvalidAudienceError < DecodeError; end 22 | 23 | # Is raised when 'iss' does not match. 24 | class InvalidIssuerError < DecodeError; end 25 | 26 | # Is raised when 'sub' claim does not match. 27 | class InvalidSubjectError < DecodeError; end 28 | 29 | # Is raised when 'jti' claim is invalid. 30 | class InvalidJtiError < DecodeError; end 31 | 32 | # Is raised when 'typ' header does not match. 33 | class InvalidTypError < DecodeError; end 34 | end 35 | -------------------------------------------------------------------------------- /spec/integration/claims/nbf_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe "nbf claim" do 4 | context "nbf is in the future" do 5 | it "raises ImmatureSignatureError" do 6 | nbf = Time.utc.to_unix + 2 7 | payload = {"nbf" => nbf} 8 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 9 | expect_raises(JWT::ImmatureSignatureError, "Signature nbf has not been reached") do 10 | JWT.decode(token, "key", JWT::Algorithm::HS256) 11 | end 12 | end 13 | end 14 | 15 | context "nbf is now" do 16 | it "accepts token" do 17 | nbf = Time.utc.to_unix 18 | payload = {"nbf" => nbf} 19 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 20 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 21 | payload.should eq({"nbf" => nbf}) 22 | end 23 | end 24 | 25 | context "nbf is in the past" do 26 | it "accepts token" do 27 | nbf = Time.utc.to_unix - 1 28 | payload = {"nbf" => nbf} 29 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 30 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 31 | payload.should eq({"nbf" => nbf}) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 6 * * 1" 7 | jobs: 8 | coding_standards: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v5 12 | - name: Install Crystal 13 | uses: crystal-lang/install-crystal@v1 14 | - name: Install Dependencies 15 | run: shards install 16 | - name: Ameba 17 | run: ./bin/ameba 18 | - name: Format 19 | run: crystal tool format --check 20 | test: 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | experimental: [false] 25 | os: 26 | - ubuntu-latest 27 | - macos-latest 28 | - windows-latest 29 | crystal: 30 | - latest 31 | include: 32 | - experimental: true 33 | os: ubuntu-latest 34 | crystal: nightly 35 | runs-on: ${{ matrix.os }} 36 | continue-on-error: ${{ matrix.experimental }} 37 | steps: 38 | - uses: actions/checkout@v5 39 | - name: Install Crystal 40 | uses: crystal-lang/install-crystal@v1 41 | with: 42 | crystal: ${{ matrix.crystal }} 43 | - name: Install dependencies 44 | run: shards install --ignore-crystal-version --skip-postinstall --skip-executables 45 | - name: Run tests 46 | run: crystal spec -v --error-trace 47 | -------------------------------------------------------------------------------- /spec/fixtures/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDhMbKVJKA13+JE 3 | 1HDFh4Xb841HbejOlmPovfNlNdY/jtt/qvEZgL2+bDp4iSm+YE8s1DkLuhVMklw2 4 | +eDBiD9WcTE+jdgvg9iLnIXZGJuTmOPVGDFVs4RPu44aZPAd17XWH1O8/9e8t+hd 5 | swURdOP5I8PaKuqf31fyg2F3E8LoKk7bwnqz8glKZGc+ocr1Im/I90rZDPuyKDTq 6 | 0Vx+1Cqqulssf9VmSIRm1x/84pwWiD8W5hYkINNKqoobk2jW0Fpgne9GCIXXJQKD 7 | Ph6Q9PGIXYOOlB7FTumwNmWNqQptv+siPqep8jwIN16ZHsD9a2SvIKKp6UpmQ6Wt 8 | sNiYLYTHAgMBAAECggEAZnx5q8qpZxtnHLAaSqtszjc/etyYcTycZ5XbKZqg0Pgx 9 | CR9A7rxankkfKzAxYUTvg5VqCN49R0Xs1dBO64UYXjzRjoh7dNRqPs+TUsQSOdAB 10 | EEAcY8Z5VtgiyfxaExrS1IoZUBCXnHmONOZ+Tf2GqyINxkJ12SCKJAKK3uiNcZ7E 11 | 4KFQcFoMNSwyPYebeHJY1b/QiUkuQGasgbKq/LRK/avoDsZqqXEFsl0CmjT83q4m 12 | mnJGoQ+mZdXrT7PhrHSuhAliCjOVrpcA95SiYYODSZTRhQAaTWyEtCxtzVLW66wd 13 | SlP2GJRjYprsokxK/aJSZ5a75oWGwp9MycLqsZAzgQKBgQD520B1g+hTRi0rvSfv 14 | H06nzwClEQhJzu6avAEAv26zJ8CCFN/8E0mO6WJMt3TbIe3zZVXC5wY5/IUdoMg/ 15 | CotI8LneIDKfQj5oR6JA/CeM97h+p4yHCEqidjNEm1gq3F6TKfadyKefz8NH9OuV 16 | xSGYnT1QdEHvsJhFbFGWKYYnoQKBgQDmuzTHvEPDLCh5emwIBUsggHj0OLeSKwGZ 17 | JlLeC3LtgznLZ6qd8oql4Nwj+fPLq4jTnFa5JKO+mEAFhRVGdm+DCXpftDuPZJJs 18 | TuPTpAeYvbAZXbzFZ5LGJdUJnqjL8Udy3ZPBVLaI3ZtsPSkDNL2icUnBpFi0b66k 19 | hC+ELL6zZwKBgQCkV92/LkNLOvaNNX8l3t7aq+LNsDVWbwEs0AqM1l3XA4exSE/H 20 | u1v/32zj7yuy009zcC0H5POIgpuAUHhGVwYktQpcm6sQa/8mki+cNP9CztIQpN5f 21 | F0oYME0qjE9i6c5fWBO51vjCumLFWsj2m1Ks0og4+i9AnFScHVJQxgFS4QKBgGjz 22 | KlTX67KxOU/YQZ98WOnVxi7ARa+05Cs3ZWYeqw4iLH27D758flhpaXjrztsLkVc/ 23 | u6rHYbvmIOjh2gWNBbgC7ajuoFDfHEMrtzGjYNk3HwzKuHzKWdbLipWvl3P8sdUr 24 | uHOuBsFGm9WmFCujJ8bl5Pixuq+IRVYL0lk6yRttAoGATAIJjMpP07Y52kIktzNI 25 | 4fEwLjB8aP9Hu6u0nrsNhjp2JrSNI9Vo6v//LhhIXumnaZyYWNo9g3LorGeFSQVQ 26 | WXcWW+rmO7H0KELaE0trJh6QCbGqUMXrcdRRZGuYWjcbxDohecEhPc613G/SZSMN 27 | GWlsGDGhnbl3fDNpIisZxjs= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /spec/integration/claims/iss_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe "iss claim" do 4 | context "token does not contain iss" do 5 | context ":iss option is passed to .decode" do 6 | it "raises InvalidIssuerError" do 7 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256) 8 | expect_raises(JWT::InvalidIssuerError, "Invalid issuer (iss). Expected \"TEJO\", received nothing") do 9 | JWT.decode(token, "key", JWT::Algorithm::HS256, iss: "TEJO") 10 | end 11 | end 12 | end 13 | 14 | context ":iss option is not passed" do 15 | it "accepts the token" do 16 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256) 17 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 18 | payload.should eq({"foo" => "bar"}) 19 | end 20 | end 21 | end 22 | 23 | context "token contains iss" do 24 | context ":iss option is passed" do 25 | context "iss matches" do 26 | it "accepts token" do 27 | token = JWT.encode({"iss" => "TEJO"}, "key", JWT::Algorithm::HS256) 28 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256, iss: "TEJO") 29 | payload.should eq({"iss" => "TEJO"}) 30 | end 31 | end 32 | 33 | context "iss does not match" do 34 | it "raises InvalidIssuerError" do 35 | token = JWT.encode({"iss" => "TEJO"}, "key", JWT::Algorithm::HS256) 36 | expect_raises(JWT::InvalidIssuerError, "Invalid issuer (iss). Expected \"UEA\", received \"TEJO\"") do 37 | JWT.decode(token, "key", JWT::Algorithm::HS256, iss: "UEA") 38 | end 39 | end 40 | end 41 | end 42 | 43 | context ":iss option is not passed" do 44 | it "accepts token" do 45 | token = JWT.encode({"iss" => "TEJO"}, "key", JWT::Algorithm::HS256) 46 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 47 | payload.should eq({"iss" => "TEJO"}) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/integration/algorithms/hmac_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe JWT do 4 | secret_key = "$ecretKey" 5 | wrong_key = "WrongKey" 6 | payload = {"foo" => "bar"} 7 | 8 | algorithms = { 9 | JWT::Algorithm::HS256 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmb28iOiJiYXIifQ.JrpaO9b4_55fVBXe8LgOIkKBTjSE7-pqm5pfzh9wzOM", 10 | JWT::Algorithm::HS384 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzM4NCJ9.eyJmb28iOiJiYXIifQ.l7UMuFdyQGfcI06CfxK9xk7NmGbRShs7IDdQ5qVi8MXlaCn1o6WEQyJTduOEbPhp", 11 | JWT::Algorithm::HS512 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJmb28iOiJiYXIifQ.cuIGPzgyhGTXJzO7FojzjcH7wZDc2005e1MChS-5KJOo1ON4g_k3ZSyxcKiE7rK8VJuVnL7X7EM2GQG2mVgOxQ", 12 | } 13 | 14 | algorithms.each do |alg, expected_token| 15 | describe "algorithm #{alg}" do 16 | it "generates proper token, that can be decoded" do 17 | token = JWT.encode(payload, secret_key, alg) 18 | token.should eq(expected_token) 19 | 20 | decoded_token = JWT.decode(token, secret_key, alg) 21 | decoded_token[0].should eq(payload) 22 | decoded_token[1].should eq({"typ" => "JWT", "alg" => alg.to_s}) 23 | end 24 | 25 | describe "#decode" do 26 | context "when token was signed with another key" do 27 | it "raises JWT::VerificationError" do 28 | token = JWT.encode(payload, wrong_key, alg) 29 | expect_raises(JWT::VerificationError, "Signature verification failed") do 30 | JWT.decode(token, secret_key, alg) 31 | end 32 | end 33 | 34 | it "can ignore verification if requested" do 35 | token = JWT.encode(payload, wrong_key, alg) 36 | JWT.decode(token, verify: false) 37 | end 38 | end 39 | 40 | context "when token contains not 3 segments" do 41 | it "raises JWT::DecodeError" do 42 | ["e30", "e30.e30", "e30.e30.e30.e30"].each do |invalid_token| 43 | expect_raises(JWT::DecodeError) do 44 | JWT.decode(invalid_token, secret_key, alg) 45 | end 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/integration/claims/sub_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe "sub claim" do 4 | context "token does not contain sub" do 5 | context ":sub option is passed to .decode" do 6 | it "raises InvalidSubjectError" do 7 | payload = {"foo" => "bar"} 8 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 9 | expect_raises(JWT::InvalidSubjectError, "Invalid subject (sub). Expected \"TEJO\", received nothing") do 10 | JWT.decode(token, "key", JWT::Algorithm::HS256, sub: "TEJO") 11 | end 12 | end 13 | end 14 | 15 | context ":sub option is not passed to .decode" do 16 | it "accepts token" do 17 | payload = {"foo" => "bar"} 18 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 19 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 20 | payload.should eq({"foo" => "bar"}) 21 | end 22 | end 23 | end 24 | 25 | context "token contains sub" do 26 | context ":sub option is passed to .decode" do 27 | context "sub does not match" do 28 | it "raises InvalidSubjectError" do 29 | payload = {"sub" => "Esperanto"} 30 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 31 | expect_raises(JWT::InvalidSubjectError, "Invalid subject (sub). Expected \"Junularo\", received \"Esperanto\"") do 32 | JWT.decode(token, "key", JWT::Algorithm::HS256, sub: "Junularo") 33 | end 34 | end 35 | end 36 | 37 | context "sub matches" do 38 | it "accepts the token" do 39 | payload = {"sub" => "Esperanto"} 40 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 41 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256, sub: "Esperanto") 42 | payload.should eq({"sub" => "Esperanto"}) 43 | end 44 | end 45 | end 46 | 47 | context ":sub option is not passed to .decode" do 48 | it "accepts token" do 49 | payload = {"sub" => "Esperanto"} 50 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 51 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 52 | payload.should eq({"sub" => "Esperanto"}) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /examples/pss_and_eddsa_usage.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | 3 | puts "=== RSA-PSS Algorithms (PS256, PS384, PS512) ===" 4 | puts 5 | 6 | # Generate RSA keys for PS256/PS384 (1024-bit is minimum, but 2048+ recommended) 7 | rsa_key = OpenSSL::PKey::RSA.new(2048) 8 | private_key = rsa_key.to_pem 9 | public_key = rsa_key.public_key.to_pem 10 | 11 | payload = {"user" => "john_doe", "exp" => (Time.utc.to_unix + 3600).to_i64} 12 | 13 | # PS256 Example 14 | puts "PS256 (RSA-PSS with SHA-256):" 15 | token_ps256 = JWT.encode(payload, private_key, JWT::Algorithm::PS256) 16 | puts "Token: #{token_ps256[0..50]}..." 17 | decoded_ps256 = JWT.decode(token_ps256, public_key, JWT::Algorithm::PS256) 18 | puts "Decoded: #{decoded_ps256[0]}" 19 | puts 20 | 21 | # PS384 Example 22 | puts "PS384 (RSA-PSS with SHA-384):" 23 | token_ps384 = JWT.encode(payload, private_key, JWT::Algorithm::PS384) 24 | puts "Token: #{token_ps384[0..50]}..." 25 | decoded_ps384 = JWT.decode(token_ps384, public_key, JWT::Algorithm::PS384) 26 | puts "Decoded: #{decoded_ps384[0]}" 27 | puts 28 | 29 | # PS512 Example (requires 2048-bit key minimum) 30 | puts "PS512 (RSA-PSS with SHA-512):" 31 | token_ps512 = JWT.encode(payload, private_key, JWT::Algorithm::PS512) 32 | puts "Token: #{token_ps512[0..50]}..." 33 | decoded_ps512 = JWT.decode(token_ps512, public_key, JWT::Algorithm::PS512) 34 | puts "Decoded: #{decoded_ps512[0]}" 35 | puts 36 | 37 | puts "Note: RSA-PSS uses random salt, so tokens are different each time:" 38 | token1 = JWT.encode(payload, private_key, JWT::Algorithm::PS256) 39 | token2 = JWT.encode(payload, private_key, JWT::Algorithm::PS256) 40 | puts "Token 1: #{token1[0..50]}..." 41 | puts "Token 2: #{token2[0..50]}..." 42 | puts "Same content: #{token1 == token2 ? "Yes" : "No"}" 43 | puts 44 | 45 | puts "=== EdDSA (Ed25519) Algorithm ===" 46 | puts 47 | 48 | # Generate Ed25519 key (32 bytes) 49 | ed_private_key_bytes = Bytes[ 50 | 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 51 | 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c, 0xc4, 52 | 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 53 | 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae, 0x7f, 0x60, 54 | ] 55 | ed_private_key = ed_private_key_bytes.hexstring 56 | 57 | puts "EdDSA (Edwards-curve Digital Signature Algorithm):" 58 | token_eddsa = JWT.encode(payload, ed_private_key, JWT::Algorithm::EdDSA) 59 | puts "Token: #{token_eddsa[0..50]}..." 60 | decoded_eddsa = JWT.decode(token_eddsa, ed_private_key, JWT::Algorithm::EdDSA) 61 | puts "Decoded: #{decoded_eddsa[0]}" 62 | puts 63 | 64 | puts "Note: EdDSA signatures are deterministic, so tokens are identical:" 65 | token1 = JWT.encode(payload, ed_private_key, JWT::Algorithm::EdDSA) 66 | token2 = JWT.encode(payload, ed_private_key, JWT::Algorithm::EdDSA) 67 | puts "Token 1: #{token1[0..50]}..." 68 | puts "Token 2: #{token2[0..50]}..." 69 | puts "Same content: #{token1 == token2 ? "Yes" : "No"}" 70 | -------------------------------------------------------------------------------- /examples/es256k_usage.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | 3 | puts "=== ES256K Algorithm (ECDSA with secp256k1) ===" 4 | puts 5 | puts "ES256K uses the secp256k1 elliptic curve, which is the same curve" 6 | puts "used by Bitcoin and Ethereum. This makes it ideal for blockchain" 7 | puts "and cryptocurrency applications." 8 | puts 9 | 10 | # Generate secp256k1 key pair 11 | ec_key = OpenSSL::PKey::EC.generate_by_curve_name("secp256k1") 12 | private_key = ec_key.to_pem 13 | public_key = ec_key.public_key.to_pem 14 | 15 | puts "Generated secp256k1 key pair" 16 | puts 17 | 18 | # Create a JWT with blockchain-related payload 19 | payload = { 20 | "sub" => "user@example.com", 21 | "chain" => "ethereum", 22 | "address" => "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", 23 | "iat" => Time.utc.to_unix.to_i64, 24 | "exp" => (Time.utc.to_unix + 3600).to_i64, 25 | } 26 | 27 | puts "Encoding JWT with ES256K..." 28 | token = JWT.encode(payload, private_key, JWT::Algorithm::ES256K) 29 | puts "Token: #{token[0..80]}..." 30 | puts 31 | 32 | # Decode and verify 33 | puts "Decoding and verifying JWT..." 34 | decoded_payload, decoded_header = JWT.decode(token, public_key, JWT::Algorithm::ES256K) 35 | 36 | puts "Header:" 37 | puts " Algorithm: #{decoded_header["alg"]}" 38 | puts " Type: #{decoded_header["typ"]}" 39 | puts 40 | 41 | puts "Payload:" 42 | puts " Subject: #{decoded_payload["sub"]}" 43 | puts " Chain: #{decoded_payload["chain"]}" 44 | puts " Address: #{decoded_payload["address"]}" 45 | puts 46 | 47 | # Demonstrate signature randomness 48 | puts "=== ECDSA Signature Randomness ===" 49 | puts 50 | token1 = JWT.encode(payload, private_key, JWT::Algorithm::ES256K) 51 | token2 = JWT.encode(payload, private_key, JWT::Algorithm::ES256K) 52 | 53 | puts "Token 1: #{token1[0..80]}..." 54 | puts "Token 2: #{token2[0..80]}..." 55 | puts "Tokens are different: #{token1 != token2}" 56 | puts "Both signatures are valid!" 57 | puts 58 | 59 | # Verify both tokens 60 | JWT.decode(token1, public_key, JWT::Algorithm::ES256K) 61 | JWT.decode(token2, public_key, JWT::Algorithm::ES256K) 62 | puts "✓ Both tokens verified successfully" 63 | puts 64 | 65 | # Show signature size 66 | puts "=== Signature Details ===" 67 | parts = token.split('.') 68 | signature = Base64.decode(parts[2]) 69 | puts "Signature size: #{signature.size} bytes" 70 | puts " (32 bytes for r component + 32 bytes for s component)" 71 | puts 72 | 73 | puts "=== Blockchain Use Cases ===" 74 | puts 75 | puts "ES256K is particularly useful for:" 76 | puts " • Bitcoin and Ethereum authentication" 77 | puts " • Blockchain wallet integrations" 78 | puts " • Cryptocurrency payment systems" 79 | puts " • Web3 applications" 80 | puts " • Decentralized identity (DID)" 81 | puts 82 | puts "The secp256k1 curve is optimized for efficient signature" 83 | puts "verification and is widely supported in blockchain ecosystems." 84 | -------------------------------------------------------------------------------- /spec/jwt_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe JWT do 4 | describe "#encode" do 5 | it "encodes with HS256" do 6 | payload = {"k1" => "v1", "k2" => "v2"} 7 | key = "SecretKey" 8 | token = JWT.encode(payload, key, JWT::Algorithm::HS256) 9 | token.should eq "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrMSI6InYxIiwiazIiOiJ2MiJ9.spzfy63YQSKdoM3av9HHvLtWzFjPd1hbch2g3T1-nu4" 10 | end 11 | end 12 | 13 | describe "#decode" do 14 | it "decodes and verifies JWT" do 15 | token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrMSI6InYxIiwiazIiOiJ2MiJ9.spzfy63YQSKdoM3av9HHvLtWzFjPd1hbch2g3T1-nu4" 16 | payload, header = JWT.decode(token, "SecretKey", JWT::Algorithm::HS256) 17 | header.should eq({"typ" => "JWT", "alg" => "HS256"}) 18 | payload.should eq({"k1" => "v1", "k2" => "v2"}) 19 | end 20 | 21 | it "decodes and verifies JWT with dynamic key" do 22 | token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrMSI6InYxIiwiazIiOiJ2MiJ9.spzfy63YQSKdoM3av9HHvLtWzFjPd1hbch2g3T1-nu4" 23 | payload, header = JWT.decode(token, algorithm: JWT::Algorithm::HS256) do |_header, _payload| 24 | "SecretKey" 25 | end 26 | header.should eq({"typ" => "JWT", "alg" => "HS256"}) 27 | payload.should eq({"k1" => "v1", "k2" => "v2"}) 28 | end 29 | 30 | it "decodes and verifies JWT with dynamic key and auto algorithm" do 31 | token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrMSI6InYxIiwiazIiOiJ2MiJ9.spzfy63YQSKdoM3av9HHvLtWzFjPd1hbch2g3T1-nu4" 32 | payload, header = JWT.decode(token) do |_header, _payload| 33 | "SecretKey" 34 | end 35 | header.should eq({"typ" => "JWT", "alg" => "HS256"}) 36 | payload.should eq({"k1" => "v1", "k2" => "v2"}) 37 | end 38 | end 39 | 40 | describe "#encode_header" do 41 | it "encodes header using Base64" do 42 | encoded_header = JWT.encode_header(JWT::Algorithm::HS256) 43 | header = Base64.decode_string(encoded_header) 44 | header.should eq %({"typ":"JWT","alg":"HS256"}) 45 | end 46 | end 47 | 48 | describe "#encode_payload" do 49 | it "encodes payload with Base64" do 50 | encoded_payload = JWT.encode_payload({"name" => "Sergey", "drink" => "mate"}) 51 | payload_json = Base64.decode_string(encoded_payload) 52 | payload_json.should eq %({"name":"Sergey","drink":"mate"}) 53 | end 54 | end 55 | 56 | describe "#sign" do 57 | context "when algorithm is none" do 58 | it "returns an empty string" do 59 | result = JWT.encoded_signature(JWT::Algorithm::None, "key", "data") 60 | result.should eq "" 61 | end 62 | end 63 | 64 | context "when algorithm is HS256" do 65 | it "returns signature" do 66 | result = JWT.encoded_signature(JWT::Algorithm::HS256, "key", "data") 67 | result.should eq "UDH-PZicbRU3oBP6bnOdojRj_a7DtwE32Cjjas4iG9A" 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/integration/algorithms/eddsa_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe JWT do 4 | # Generate a test Ed25519 key pair 5 | private_key_bytes = Bytes[ 6 | 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 7 | 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c, 0xc4, 8 | 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 9 | 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae, 0x7f, 0x60, 10 | ] 11 | 12 | private_key = private_key_bytes.hexstring 13 | # public_key = Ed25519.get_public_key(private_key_bytes).hexstring 14 | 15 | wrong_key_bytes = Bytes[ 16 | 0x4c, 0xcd, 0x08, 0x9b, 0x28, 0xff, 0x96, 0xda, 17 | 0x9d, 0xb6, 0xc3, 0x46, 0xec, 0x11, 0x4e, 0x0f, 18 | 0x5b, 0x8a, 0x31, 0x9f, 0x35, 0xab, 0xa6, 0x24, 19 | 0xda, 0x8c, 0xf6, 0xed, 0x4f, 0xb8, 0xa6, 0xfb, 20 | ] 21 | wrong_key = wrong_key_bytes.hexstring 22 | 23 | payload = {"foo" => "bar"} 24 | 25 | describe "algorithm EdDSA" do 26 | it "generates token that can be decoded" do 27 | token = JWT.encode(payload, private_key, JWT::Algorithm::EdDSA) 28 | 29 | decoded_token = JWT.decode(token, private_key, JWT::Algorithm::EdDSA) 30 | decoded_token[0].should eq(payload) 31 | decoded_token[1].should eq({"typ" => "JWT", "alg" => "EdDSA"}) 32 | end 33 | 34 | it "can verify with public key" do 35 | token = JWT.encode(payload, private_key, JWT::Algorithm::EdDSA) 36 | 37 | # Decode using private key (which derives public key) 38 | decoded_token = JWT.decode(token, private_key, JWT::Algorithm::EdDSA) 39 | decoded_token[0].should eq(payload) 40 | end 41 | 42 | it "produces deterministic signatures" do 43 | token1 = JWT.encode(payload, private_key, JWT::Algorithm::EdDSA) 44 | token2 = JWT.encode(payload, private_key, JWT::Algorithm::EdDSA) 45 | 46 | # EdDSA signatures should be deterministic 47 | token1.should eq(token2) 48 | end 49 | 50 | describe "#decode" do 51 | context "when token was signed with another key" do 52 | it "raises JWT::VerificationError" do 53 | token = JWT.encode(payload, wrong_key, JWT::Algorithm::EdDSA) 54 | expect_raises(JWT::VerificationError, "Signature verification failed") do 55 | JWT.decode(token, private_key, JWT::Algorithm::EdDSA) 56 | end 57 | end 58 | 59 | it "can ignore verification if requested" do 60 | token = JWT.encode(payload, wrong_key, JWT::Algorithm::EdDSA) 61 | JWT.decode(token, verify: false) 62 | end 63 | end 64 | 65 | context "when token contains not 3 segments" do 66 | it "raises JWT::DecodeError" do 67 | ["e30", "e30.e30", "e30.e30.e30.e30"].each do |invalid_token| 68 | expect_raises(JWT::DecodeError) do 69 | JWT.decode(invalid_token, private_key, JWT::Algorithm::EdDSA) 70 | end 71 | end 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/integration/claims/aud_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe "aud claim" do 4 | context "token does not contain aud" do 5 | context "aud options is not passed to .decode method" do 6 | it "accepts token" do 7 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256) 8 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 9 | payload.should eq({"foo" => "bar"}) 10 | end 11 | end 12 | 13 | context "aud option is passed" do 14 | it "raises InvalidAudienceError" do 15 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256) 16 | expect_raises(JWT::InvalidAudienceError, "Invalid audience (aud). Expected \"sergey\", received nothing") do 17 | JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "sergey") 18 | end 19 | end 20 | end 21 | end 22 | 23 | context "token contains aud as a string" do 24 | context "aud is not passed to .decode" do 25 | it "accepts token" do 26 | token = JWT.encode({"foo" => "bar", "aud" => "sergey"}, "key", JWT::Algorithm::HS256) 27 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 28 | payload.should eq({"foo" => "bar", "aud" => "sergey"}) 29 | end 30 | end 31 | 32 | context "aud matches" do 33 | it "accepts token" do 34 | token = JWT.encode({"foo" => "bar", "aud" => "sergey"}, "key", JWT::Algorithm::HS256) 35 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "sergey") 36 | payload.should eq({"foo" => "bar", "aud" => "sergey"}) 37 | end 38 | end 39 | 40 | context "aud does not match" do 41 | it "raises InvalidAudienceError" do 42 | token = JWT.encode({"foo" => "bar", "aud" => "sergey"}, "key", JWT::Algorithm::HS256) 43 | expect_raises(JWT::InvalidAudienceError, "Invalid audience (aud). Expected \"julia\", received \"sergey\"") do 44 | JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "julia") 45 | end 46 | end 47 | end 48 | end 49 | 50 | context "token contains aud as an array of strings" do 51 | context "aud is not passed to .decode" do 52 | it "accepts token" do 53 | token = JWT.encode({"foo" => "bar", "aud" => ["sergey", "julia"]}, "key", JWT::Algorithm::HS256) 54 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 55 | payload.should eq({"foo" => "bar", "aud" => ["sergey", "julia"]}) 56 | end 57 | end 58 | 59 | context "aud matches one of items in the array" do 60 | it "accepts token" do 61 | token = JWT.encode({"foo" => "bar", "aud" => ["sergey", "julia"]}, "key", JWT::Algorithm::HS256) 62 | 63 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "julia") 64 | payload.should eq({"foo" => "bar", "aud" => ["sergey", "julia"]}) 65 | 66 | payload, _header = JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "sergey") 67 | payload.should eq({"foo" => "bar", "aud" => ["sergey", "julia"]}) 68 | end 69 | end 70 | 71 | context "aud does not match" do 72 | it "raises InvalidAudienceError" do 73 | token = JWT.encode({"foo" => "bar", "aud" => ["sergey", "julia"]}, "key", JWT::Algorithm::HS256) 74 | 75 | expect_raises(JWT::InvalidAudienceError, "Invalid audience (aud). Expected \"max\", received [\"sergey\", \"julia\"]") do 76 | JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "max") 77 | end 78 | end 79 | end 80 | end 81 | 82 | context "token contains invalid format of aud" do 83 | it "raises exception" do 84 | token = JWT.encode({"foo" => "bar", "aud" => 123}, "key", JWT::Algorithm::HS256) 85 | expect_raises(JWT::InvalidAudienceError, "aud claim must be a string or array of strings") do 86 | JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "max") 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/integration/algorithms/es256k_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe JWT do 4 | # Generate secp256k1 key (Bitcoin/Ethereum curve) 5 | ec_key = OpenSSL::PKey::EC.generate_by_curve_name("secp256k1") 6 | private_key = ec_key.to_pem 7 | public_key = ec_key.public_key.to_pem 8 | 9 | wrong_key = OpenSSL::PKey::EC.generate_by_curve_name("secp256k1").to_pem 10 | payload = {"foo" => "bar"} 11 | 12 | describe "algorithm ES256K" do 13 | it "generates token that can be decoded" do 14 | token = JWT.encode(payload, private_key, JWT::Algorithm::ES256K) 15 | 16 | decoded_token = JWT.decode(token, public_key, JWT::Algorithm::ES256K) 17 | decoded_token[0].should eq(payload) 18 | decoded_token[1].should eq({"typ" => "JWT", "alg" => "ES256K"}) 19 | 20 | decoded_token = JWT.decode(token, private_key, JWT::Algorithm::ES256K) 21 | decoded_token[0].should eq(payload) 22 | decoded_token[1].should eq({"typ" => "JWT", "alg" => "ES256K"}) 23 | end 24 | 25 | it "uses secp256k1 curve (Bitcoin/Ethereum curve)" do 26 | token = JWT.encode(payload, private_key, JWT::Algorithm::ES256K) 27 | decoded_token = JWT.decode(token, public_key, JWT::Algorithm::ES256K) 28 | decoded_token[0].should eq(payload) 29 | end 30 | 31 | it "signature size is 64 bytes (32 bytes for r, 32 bytes for s)" do 32 | token = JWT.encode(payload, private_key, JWT::Algorithm::ES256K) 33 | # JWT format: header.payload.signature 34 | parts = token.split('.') 35 | signature = Base64.decode(parts[2]) 36 | # secp256k1 with SHA-256 produces 64-byte signature (32 bytes r + 32 bytes s) 37 | signature.size.should eq(64) 38 | end 39 | 40 | it "can encode and decode with different instances" do 41 | token1 = JWT.encode(payload, private_key, JWT::Algorithm::ES256K) 42 | token2 = JWT.encode(payload, private_key, JWT::Algorithm::ES256K) 43 | 44 | # ECDSA signatures include randomness, so tokens should be different 45 | token1.should_not eq(token2) 46 | 47 | # But both should decode successfully 48 | JWT.decode(token1, public_key, JWT::Algorithm::ES256K)[0].should eq(payload) 49 | JWT.decode(token2, public_key, JWT::Algorithm::ES256K)[0].should eq(payload) 50 | end 51 | 52 | describe "#decode" do 53 | context "when token was signed with another key" do 54 | it "raises JWT::VerificationError" do 55 | token = JWT.encode(payload, wrong_key, JWT::Algorithm::ES256K) 56 | expect_raises(JWT::VerificationError, "Signature verification failed") do 57 | JWT.decode(token, public_key, JWT::Algorithm::ES256K) 58 | end 59 | end 60 | 61 | it "can ignore verification if requested" do 62 | token = JWT.encode(payload, wrong_key, JWT::Algorithm::ES256K) 63 | JWT.decode(token, verify: false) 64 | end 65 | end 66 | 67 | context "when token contains not 3 segments" do 68 | it "raises JWT::DecodeError" do 69 | ["e30", "e30.e30", "e30.e30.e30.e30"].each do |invalid_token| 70 | expect_raises(JWT::DecodeError) do 71 | JWT.decode(invalid_token, public_key, JWT::Algorithm::ES256K) 72 | end 73 | end 74 | end 75 | end 76 | end 77 | 78 | describe "blockchain/cryptocurrency compatibility" do 79 | it "ES256K uses the same curve as Bitcoin and Ethereum" do 80 | # secp256k1 is the elliptic curve used by Bitcoin and Ethereum 81 | # This makes ES256K JWTs compatible with blockchain ecosystems 82 | token = JWT.encode({"chain" => "bitcoin", "address" => "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"}, private_key, JWT::Algorithm::ES256K) 83 | decoded = JWT.decode(token, public_key, JWT::Algorithm::ES256K) 84 | decoded[0]["chain"].should eq("bitcoin") 85 | decoded[0]["address"].should eq("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /examples/jwks_entra_example.cr: -------------------------------------------------------------------------------- 1 | require "../src/jwt" 2 | 3 | # Example: Validating Entra ID (Azure AD) JWTs with JWKS 4 | # 5 | # This example demonstrates how to validate JWTs issued by Microsoft Entra ID 6 | # using the JWKS helper with OIDC discovery. 7 | 8 | # Configuration for your Entra ID tenant 9 | TENANT_ID = ENV["ENTRA_TENANT_ID"]? || "your-tenant-id" 10 | CLIENT_ID = ENV["ENTRA_CLIENT_ID"]? || "your-app-client-id" 11 | 12 | # Entra ID issuer URL 13 | issuer = "https://login.microsoftonline.com/#{TENANT_ID}/v2.0" 14 | 15 | # Expected audience (your API's client ID) 16 | audience = "api://#{CLIENT_ID}" 17 | 18 | # Initialize JWKS validator 19 | jwks = JWT::JWKS.new(cache_ttl: 10.minutes) 20 | 21 | # Example token (replace with a real token from an Authorization header) 22 | # In a real application, you'd get this from: 23 | # token = request.headers["Authorization"]?.try(&.lstrip("Bearer ")) 24 | token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImJlZDdmM2VhLWQ5YzAtNGU5MS04NDU1LWQ2NTYwNGE0YjAzMCJ9..." 25 | 26 | puts "Validating Entra ID JWT..." 27 | puts 28 | 29 | # Basic validation (signature, exp, nbf, iss, aud) 30 | payload = jwks.validate(token, issuer: issuer, audience: audience) 31 | 32 | if payload 33 | puts "✓ Token is valid!" 34 | puts 35 | puts "Token claims:" 36 | puts " Subject (oid): #{payload["oid"]?}" 37 | puts " Username: #{payload["preferred_username"]?}" 38 | puts " Name: #{payload["name"]?}" 39 | puts " Email: #{payload["email"]?}" 40 | else 41 | puts "✗ Token is invalid" 42 | exit 1 43 | end 44 | 45 | puts 46 | puts "---" 47 | puts 48 | 49 | # Validation with scope checking 50 | puts "Validating with scope requirements..." 51 | 52 | required_scopes = ["User.Read", "Mail.Read"] 53 | 54 | payload = jwks.validate(token, issuer: issuer, audience: audience) 55 | 56 | if payload 57 | # Extract scopes (Entra uses "scp" claim as an array) 58 | scopes = JWT::JWKS.extract_scopes(payload) 59 | 60 | puts " Token scopes: #{scopes}" 61 | puts " Required scopes: #{required_scopes}" 62 | 63 | # Check if all required scopes are present 64 | if JWT::JWKS.validate_scopes(payload, required_scopes) 65 | puts " ✓ All required scopes present" 66 | puts 67 | puts "Token validated successfully with required scopes!" 68 | else 69 | missing = required_scopes.reject { |scope| scopes.includes?(scope) } 70 | puts " ✗ Missing scopes: #{missing}" 71 | puts 72 | puts "Token validation failed (missing scopes)" 73 | exit 1 74 | end 75 | else 76 | puts 77 | puts "Token validation failed (invalid token)" 78 | exit 1 79 | end 80 | 81 | puts 82 | puts "---" 83 | puts 84 | 85 | # Role-based validation (if your app uses app roles) 86 | puts "Validating with role requirements..." 87 | 88 | required_roles = ["Admin"] 89 | 90 | payload = jwks.validate(token, issuer: issuer, audience: audience) 91 | 92 | if payload 93 | roles = JWT::JWKS.extract_roles(payload) 94 | 95 | puts " Token roles: #{roles}" 96 | puts " Required roles: #{required_roles}" 97 | 98 | if roles.any? { |role| required_roles.includes?(role) } 99 | puts " ✓ User has required role" 100 | puts 101 | puts "Token validated successfully with required roles!" 102 | else 103 | puts " ✗ User doesn't have required role" 104 | puts 105 | puts "Token validation failed (missing roles)" 106 | end 107 | else 108 | puts 109 | puts "Token validation failed (invalid token)" 110 | end 111 | 112 | puts 113 | puts "---" 114 | puts 115 | 116 | # Helper methods for scope/role checking 117 | puts "Using helper methods..." 118 | 119 | payload = jwks.validate(token, issuer: issuer, audience: audience) 120 | 121 | if payload 122 | # Check specific scopes 123 | has_user_read = JWT::JWKS.validate_scopes(payload, ["User.Read"]) 124 | has_mail_send = JWT::JWKS.validate_scopes(payload, ["Mail.Send"]) 125 | 126 | puts " Has User.Read scope: #{has_user_read}" 127 | puts " Has Mail.Send scope: #{has_mail_send}" 128 | 129 | # Check specific roles 130 | is_admin = JWT::JWKS.validate_roles(payload, ["Admin"]) 131 | is_user = JWT::JWKS.validate_roles(payload, ["User"]) 132 | 133 | puts " Is Admin: #{is_admin}" 134 | puts " Is User: #{is_user}" 135 | end 136 | -------------------------------------------------------------------------------- /spec/integration/algorithms/rsa_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe JWT do 4 | private_key = "-----BEGIN PRIVATE KEY-----\n" + 5 | "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALn7n7GdaCDE2eeY\n" + 6 | "JI1sjBNAWNzJrBy+Y+6l5ezXSx+FQAdTG2ZPnMfcAjjomtFk3spXkBzltBbMX1kw\n" + 7 | "94eqarkUF1iiggXxbuVW1jHbc5Bfm+MVE3QtFjyHI4ovTtSz5pR4zANdfszqjnxc\n" + 8 | "7huo6HykY6oUuxwICR0A/2UOB4MbAgMBAAECgYBxG3+OdI1sSGvBdnzcaaRy3NJu\n" + 9 | "TFRZEs0RyWEg/fpZDB/ZlIh4W3ic78eGNqhZKoB4DHK/sE8rAlYGl0oi/thx9u7Z\n" + 10 | "zUnFaBpy6i17AyTkhg9dSzz1BjcAvkjgEl1mp3ej0rg5bBqS6SR+PEcoUL+CuJ81\n" + 11 | "rjJVDohmf5e5b8CymQJBAOWbORRqnODSfS3eCYropAP1/lh1cpgZjNg7dJyu2vPn\n" + 12 | "d97Cp8Nd0sLtMYv2rD28YQW9ITvbu/BHdf74NnpFZi8CQQDPXKpQ2es/DMbHcm4g\n" + 13 | "0heB/MZOriBJ/7FGNvmoMlQ+cy3gjc+s/JWmbfhpQKCjbqCjb6K0EaPMUNokI5Pi\n" + 14 | "LOLVAkAmroTqRJ/TXILMVGjlJxZiuHG2M2sv5rYMw898ihTHHIrcU4zx4/+a6Vz8\n" + 15 | "iH0yFWd/EQLlU7qQ22ksoGKFLOXvAkEAmtHh67m4lXuRkmoSZXjWyluTKD2DqBw7\n" + 16 | "HGSBZB4nnfTbBPR8YPi5NuiWduckyMEZOM1p2i3tcOfQ5viVOmIu/QJBAOQQKmlh\n" + 17 | "Dg3R5x6CDXE9Wp/X18ej9YYca5JyN9Q9Mj+TQtomNgzbJx6GKea4seBjep0MmC0R\n" + 18 | "u3hYblc1DOHJ9o0=\n" + 19 | "-----END PRIVATE KEY-----\n" 20 | 21 | public_key = "-----BEGIN PUBLIC KEY-----\n" + 22 | "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5+5+xnWggxNnnmCSNbIwTQFjc\n" + 23 | "yawcvmPupeXs10sfhUAHUxtmT5zH3AI46JrRZN7KV5Ac5bQWzF9ZMPeHqmq5FBdY\n" + 24 | "ooIF8W7lVtYx23OQX5vjFRN0LRY8hyOKL07Us+aUeMwDXX7M6o58XO4bqOh8pGOq\n" + 25 | "FLscCAkdAP9lDgeDGwIDAQAB\n" + 26 | "-----END PUBLIC KEY-----\n" 27 | 28 | wrong_key = OpenSSL::PKey::RSA.new(1024).to_pem 29 | payload = {"foo" => "bar"} 30 | 31 | algorithms = { 32 | JWT::Algorithm::RS256 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJmb28iOiJiYXIifQ.rJUpucYWdjmGiVGHrU4TMwfYNcF52Hm1Q4hJfHhfUPvVL-S0fRHRgwNns90MDOFReXH8_6swbtezzeuQleSY-NdYLEvnXwYHzjLP-Bxc3mrKNMnf8ta1lYB7NqdnIu2nqcNjflJBubn5sIi7-zZew_ohqgMP8H7ptDuICr7ibGQ", 33 | JWT::Algorithm::RS384 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzM4NCJ9.eyJmb28iOiJiYXIifQ.FfdS8chkIE-PRU61h8VLZgVYvKI3yAvaEpGjqDP0ypGa_0rF6iOCkRuEByhBsH-lCVmKcU-1bp3OsEGXtuYlthpklM76gDDP4YMss2mdH4_xr6P9UQ7lL_xb8inOCbnNMsm7xecIPElDkJ5W22iwF2fbi67p9hlJwgcfBsyfqX4", 34 | JWT::Algorithm::RS512 => "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJmb28iOiJiYXIifQ.StG6Du1SpGrP7BdFyW6VjMwHudEdekdlJjbT1ByWFPerp7hZ1P7ukHOFMzVVOm6e0xLO6XGk11jDvC_zG2wunjEoMKYY_DuSmUOjVcZVz5m5korH9PJNJRREoQPa42QTVUaMeuv8A3xlq6_SG9wLCGVib4JsIFyS1qPzS3PlNZg", 35 | } 36 | 37 | algorithms.each do |alg, expected_token| 38 | describe "algorithm #{alg}" do 39 | it "generates proper token, that can be decoded" do 40 | token = JWT.encode(payload, private_key, alg) 41 | token.should eq(expected_token) 42 | 43 | decoded_token = JWT.decode(token, public_key, alg) 44 | decoded_token[0].should eq(payload) 45 | decoded_token[1].should eq({"typ" => "JWT", "alg" => alg.to_s}) 46 | 47 | decoded_token = JWT.decode(token, private_key, alg) 48 | decoded_token[0].should eq(payload) 49 | decoded_token[1].should eq({"typ" => "JWT", "alg" => alg.to_s}) 50 | end 51 | 52 | describe "#decode" do 53 | context "when token was signed with another key" do 54 | it "raises JWT::VerificationError" do 55 | token = JWT.encode(payload, wrong_key, alg) 56 | expect_raises(JWT::VerificationError, "Signature verification failed") do 57 | JWT.decode(token, public_key, alg) 58 | end 59 | end 60 | 61 | it "can ignore verification if requested" do 62 | token = JWT.encode(payload, wrong_key, alg) 63 | JWT.decode(token, verify: false) 64 | end 65 | end 66 | 67 | context "when token contains not 3 segments" do 68 | it "raises JWT::DecodeError" do 69 | ["e30", "e30.e30", "e30.e30.e30.e30"].each do |invalid_token| 70 | expect_raises(JWT::DecodeError) do 71 | JWT.decode(invalid_token, public_key, alg) 72 | end 73 | end 74 | end 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/integration/claims/typ_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe "typ header" do 4 | context "token does not contain typ" do 5 | context ":typ option is passed to .decode" do 6 | it "raises InvalidTypError" do 7 | # Create a token without typ header (removing default typ) 8 | payload = {"foo" => "bar"} 9 | segments = [] of String 10 | header = {alg: "HS256"}.to_json 11 | segments << Base64.urlsafe_encode(header, false) 12 | segments << Base64.urlsafe_encode(payload.to_json, false) 13 | signature = OpenSSL::HMAC.digest(:sha256, "key", segments.join(".")) 14 | segments << Base64.urlsafe_encode(signature, false) 15 | token = segments.join(".") 16 | 17 | expect_raises(JWT::InvalidTypError, "Invalid type (typ). Expected \"JWT\", received nothing") do 18 | JWT.decode(token, "key", JWT::Algorithm::HS256, typ: "JWT") 19 | end 20 | end 21 | end 22 | 23 | context ":typ option is not passed" do 24 | it "accepts the token" do 25 | # Create a token without typ header 26 | payload = {"foo" => "bar"} 27 | segments = [] of String 28 | header = {alg: "HS256"}.to_json 29 | segments << Base64.urlsafe_encode(header, false) 30 | segments << Base64.urlsafe_encode(payload.to_json, false) 31 | signature = OpenSSL::HMAC.digest(:sha256, "key", segments.join(".")) 32 | segments << Base64.urlsafe_encode(signature, false) 33 | token = segments.join(".") 34 | 35 | payload_result, _header = JWT.decode(token, "key", JWT::Algorithm::HS256) 36 | payload_result.should eq({"foo" => "bar"}) 37 | end 38 | end 39 | end 40 | 41 | context "token contains typ" do 42 | context ":typ option is passed" do 43 | context "typ matches" do 44 | it "accepts token" do 45 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256) 46 | payload, header = JWT.decode(token, "key", JWT::Algorithm::HS256, typ: "JWT") 47 | payload.should eq({"foo" => "bar"}) 48 | header["typ"].should eq("JWT") 49 | end 50 | 51 | it "accepts lowercase token" do 52 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256, typ: "jwt") 53 | payload, header = JWT.decode(token, "key", JWT::Algorithm::HS256, typ: "JWT") 54 | payload.should eq({"foo" => "bar"}) 55 | header["typ"].should eq("jwt") 56 | end 57 | 58 | it "accepts token with custom typ" do 59 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256, typ: "at+jwt") 60 | payload, header = JWT.decode(token, "key", JWT::Algorithm::HS256, typ: "at+jwt") 61 | payload.should eq({"foo" => "bar"}) 62 | header["typ"].should eq("at+jwt") 63 | end 64 | end 65 | 66 | context "typ does not match" do 67 | it "raises InvalidTypError" do 68 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256) 69 | expect_raises(JWT::InvalidTypError, "Invalid type (typ). Expected \"at+jwt\", received \"JWT\"") do 70 | JWT.decode(token, "key", JWT::Algorithm::HS256, typ: "at+jwt") 71 | end 72 | end 73 | end 74 | end 75 | 76 | context ":typ option is not passed" do 77 | it "accepts token" do 78 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256) 79 | payload, header = JWT.decode(token, "key", JWT::Algorithm::HS256) 80 | payload.should eq({"foo" => "bar"}) 81 | header["typ"].should eq("JWT") 82 | end 83 | end 84 | end 85 | 86 | context "with dynamic key block" do 87 | it "validates typ with block" do 88 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256, typ: "at+jwt") 89 | payload, header = JWT.decode(token, typ: "at+jwt") do |_header, _payload| 90 | "key" 91 | end 92 | payload.should eq({"foo" => "bar"}) 93 | header["typ"].should eq("at+jwt") 94 | end 95 | 96 | it "raises InvalidTypError when typ does not match with block" do 97 | token = JWT.encode({"foo" => "bar"}, "key", JWT::Algorithm::HS256) 98 | expect_raises(JWT::InvalidTypError) do 99 | JWT.decode(token, typ: "at+jwt") do |_header, _payload| 100 | "key" 101 | end 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/integration/algorithms/ecdsa_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe JWT do 4 | wrong_key = OpenSSL::PKey::EC.new(256).to_pem 5 | payload = {"foo" => "bar"} 6 | 7 | # To generate a key: OpenSSL::PKey::EC.new(521) 8 | algorithms = { 9 | JWT::Algorithm::ES256 => { 10 | "-----BEGIN EC PRIVATE KEY-----\n" + 11 | "MHcCAQEEICQ13objo8V5wNl7ioToptpI6nJ2fvNcy+fgWQ2BrzgnoAoGCCqGSM49\n" + 12 | "AwEHoUQDQgAEUeAfjUi57m5PZ7UEiaBLUzex/Jsq0l+dC5XixCUe01qqZJ3vFe7e\n" + 13 | "zVdalVZaibmLJQ2VUgPRTrlT2yv462U6xg==\n" + 14 | "-----END EC PRIVATE KEY-----\n", 15 | "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJmb28iOiJiYXIifQ.FG1b5ByP6eIoXsXvrdTpjP4fwGWOj1qxeyPkxyXulsPYUYVor5Va8uSGUkrn41lUrR9gC1LiwnY1XPXEC_CDsA", 16 | }, 17 | 18 | JWT::Algorithm::ES384 => { 19 | "-----BEGIN EC PRIVATE KEY-----\n" + 20 | "MIGkAgEBBDBfbYWrSPvOC+KI7viJp4p0ZDu225CMXqzZ6psAja5JOur6kPU2Bj+1\n" + 21 | "mE0qtiXaVgGgBwYFK4EEACKhZANiAAQG0H1oa1HMsWVXKL8pi7PqrfY3QYnh5qRg\n" + 22 | "bIZkFLnnOikYnfy4+C4ldfja4Q1Sol2nnsQFntbkK0LMDwuJfh1hF9qUDyUnWNcZ\n" + 23 | "Evgh9T/ee7vu8MwVeAfqVHhby7xlCnk=\n" + 24 | "-----END EC PRIVATE KEY-----\n", 25 | "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJmb28iOiJiYXIifQ.Gpkkby0V5KBrpi09bQxgs2CDs-joh8RL1fmVhd-sAgUplJZS1-IwsFAh2-si7Mcf9RIp4OKA3QaMICYut3Or3ewj5rYuwOw4qhBUITYPveBgXhPknM_svg2Le44ZL1Zx", 26 | }, 27 | 28 | # https://tools.ietf.org/html/rfc7518#section-3.4 29 | # NOTE:: key size 521 for ES512 30 | JWT::Algorithm::ES512 => { 31 | "-----BEGIN EC PRIVATE KEY-----\n" + 32 | "MIHcAgEBBEIBUqevSmQr97G1/QaHaORzABsXB7oFH9kQ3ofpzyDWRMuoUAO1yuKU\n" + 33 | "XDLvv6K2bla/6Jjajs0iaFtYfjQkELmquPqgBwYFK4EEACOhgYkDgYYABADKd6je\n" + 34 | "zsb/nsKV2Fgftt+uzKGFTiq9QD2jvo/xbwEKO/JMc9okIO6S2D8PxvtaM8V5uWa/\n" + 35 | "36XJ4ZqYMFyT4r6SFQCJ+0zfMTvZWiLZxSoPaUhd/amPe5NBM3qy2qdNBXjW8SW4\n" + 36 | "r8wUDIQIjFY3yId6nm7UoILcWV2DfH9zG2ZlaRuivg==\n" + 37 | "-----END EC PRIVATE KEY-----\n", 38 | "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzUxMiJ9.eyJmb28iOiJiYXIifQ.AeXp0TWi_GK76s0Skjltw5a03EjmwndrBCGld4aOATastK7WAymbHIfXSJW6G5YhHYQ0N8unnWKQtr1SGUsAntiIARbP_dEaYvdmZu_5ypZ_fhxCymRYQ0hIxRNVclxdEGO9P6ckBRXtGfyCEGyBr3Czn6fogCrfRe0epUvyNRWUvuNH", 39 | }, 40 | } 41 | 42 | describe "ES* ASN1 conversion" do 43 | it "raw to ASN1" do 44 | key_raw, example = algorithms[JWT::Algorithm::ES256] 45 | private_key = OpenSSL::PKey::EC.new(key_raw) 46 | public_key = private_key.public_key 47 | 48 | _verify_data, _, encoded_signature = example.rpartition('.') 49 | signature = Base64.decode(encoded_signature) 50 | 51 | JWT.raw_to_asn1(signature, public_key).should eq( 52 | Bytes[ 53 | 48, 69, 2, 32, 20, 109, 91, 228, 28, 143, 233, 226, 40, 94, 197, 54 | 239, 173, 212, 233, 140, 254, 31, 192, 101, 142, 143, 90, 177, 123, 55 | 35, 228, 199, 37, 238, 150, 195, 2, 33, 0, 216, 81, 133, 104, 175, 56 | 149, 90, 242, 228, 134, 82, 74, 231, 227, 89, 84, 173, 31, 96, 11, 82, 57 | 226, 194, 118, 53, 92, 245, 196, 11, 240, 131, 176, 58 | ] 59 | ) 60 | end 61 | end 62 | 63 | algorithms.each do |alg, (private_key, example_token)| 64 | describe "algorithm #{alg}" do 65 | public_key = OpenSSL::PKey::EC.new(private_key).public_key.to_pem 66 | 67 | it "generates proper token, that can be decoded" do 68 | # encode and decode 69 | token = JWT.encode(payload, private_key, alg) 70 | decoded_token = JWT.decode(token, public_key, alg) 71 | decoded_token[0].should eq(payload) 72 | decoded_token[1].should eq({"typ" => "JWT", "alg" => alg.to_s}) 73 | 74 | # Decode the example 75 | decoded_token = JWT.decode(example_token, public_key, alg) 76 | decoded_token[0].should eq(payload) 77 | decoded_token[1].should eq({"typ" => "JWT", "alg" => alg.to_s}) 78 | 79 | decoded_token = JWT.decode(example_token, private_key, alg) 80 | decoded_token[0].should eq(payload) 81 | decoded_token[1].should eq({"typ" => "JWT", "alg" => alg.to_s}) 82 | end 83 | 84 | describe "#decode" do 85 | context "when token was signed with another key" do 86 | it "raises JWT::VerificationError" do 87 | token = JWT.encode(payload, wrong_key, alg) 88 | expect_raises(JWT::VerificationError, "Signature verification failed") do 89 | JWT.decode(token, public_key, alg) 90 | end 91 | end 92 | 93 | it "can ignore verification if requested" do 94 | token = JWT.encode(payload, wrong_key, alg) 95 | JWT.decode(token, verify: false) 96 | end 97 | end 98 | 99 | context "when token contains not 3 segments" do 100 | it "raises JWT::DecodeError" do 101 | ["e30", "e30.e30", "e30.e30.e30.e30"].each do |invalid_token| 102 | expect_raises(JWT::DecodeError) do 103 | JWT.decode(invalid_token, public_key, alg) 104 | end 105 | end 106 | end 107 | end 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/integration/algorithms/ps256_decode_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | {% if compare_versions(LibCrypto::OPENSSL_VERSION, "3.0.0") >= 0 %} 4 | # This spec verifies compatibility with tokens from the l8w8jwt library 5 | # Reference: https://github.com/GlitchedPolygons/l8w8jwt/blob/master/examples/ps256/decode.c 6 | describe "PS256 decode compatibility" do 7 | # Token from l8w8jwt example 8 | jwt_token = "eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNvbWUta2V5LWlkLWhlcmUtMDEyMzQ1In0.eyJpYXQiOjE1ODAzNDAwMzcsImV4cCI6MTU4MDM0MDYzNywic3ViIjoiR29yZG9uIEZyZWVtYW4iLCJpc3MiOiJCbGFjayBNZXNhIiwiYXVkIjoiQWRtaW5pc3RyYXRvciIsImN0eCI6IlVuZm9yc2VlbiBDb25zZXF1ZW5jZXMiLCJhZ2UiOjI3LCJzaXplIjoxLjg1LCJhbGl2ZSI6dHJ1ZSwibnVsbHRlc3QiOm51bGx9.X4o81UkLLt1mBdoQozWPAtVIRvkX7249fs25FGqrlGzci2exAVQh6g8OzqZlhPO8_VSVGt1bTlurWPhrPwZoeViy1g86MRBLNoiuWEkPg0FFB2jhBPGF2u-cJ2YKd9VSLSjs1fcxSyfG5dKczDo_w3FUL_syNpOpWaWtvByxDn0Cez4SHfTIcaGPKsyYBKhy1t3RgFzm9mCMugRd40omPO4WFKQ1f-boO0ydfvcybEmxMBpT3DsqbKAD9oM0kFWsLMIzOXIp4Uo1J-k3utjieDwaiBu7x2g-bU_0XygnXWIfrSXtUOmntVVFe9am13fIeH-I_3SJlzhLI4QapJ-_s5xeyZ3Y8tHLs-Sqt85Bs_rnewnJpHESXn-G5eK7YTHEvC3luELNrGQlTzQIpTZLYwARikQlhBme-lqvH6hTdGwQy-jhlr41GF5hBKHArFTN0RJBRDKyGgJffDlDDsk3g9NpaZqvOqMvLBHk78TbrQnTKMKY6L7dnAoPcTcl8IgIr9lN37TKFuvAm6nDjcWQUViOO9YtDng3e8cjWaJiizGpTOct-IKn7ZXMzGRrFSmXSOWgeukP5jcwH5dU_0ICDbt2oaid7Bpm1z8EviBGNh0OmjqJ8FmsGst8zaAufpSBwCbV9OCUo84RminY6pW6Lm3BWwIbki-yUOExAWJPjN0" 9 | 10 | # RSA 4096-bit public key from l8w8jwt example 11 | rsa_public_key = "-----BEGIN PUBLIC KEY-----\n" + 12 | "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAoWFe7BbX1nWo5oaSv/Jv\n" + 13 | "IUCWsk/Vi2q8P0cGkefgN5J7MN7Kfv7lq0hl/1cZcJs81IC+GiC+V3aR2zLBNnJJ\n" + 14 | "axa4sqk+hF5DJcD2bF0B80uqPYQUXlQwki/heATnVcke8APuY0kOZykxoD0APAqw\n" + 15 | "0z5KDqgt2vA9G6keM6b9bbL+IvxM+yMk1QV0OQLh6Rkz46DyPSoUFWyXiist47PJ\n" + 16 | "KNyZAfFZx6vEivzBmqRHKe11W9oD/tN5VTQCH/UTSRfyWq/UUMFVMCksLwT6XoWI\n" + 17 | "7F5swgQkSahWkVJ93Qf8cUf1HIZYTMJBYPG4y2NDZ0+ytnH3BNXLMQXg9xbgv6B/\n" + 18 | "iaSVScI4CWIpQTAtNKnJwYg2+RhfYBC07iM56c4a+TjbCWgmd11UYc96dbw83uFR\n" + 19 | "jKZc3+SC38ITCgMuoDPNBlFJK6u8VfYylGEJolGcauVa6yZKwzsJGr5J/LANz+Zy\n" + 20 | "HZmANed+2Hjqxu/H1NGDBdvUGLQbhb/uBJ8oG8iAW5eUyjEJMX0RuncYnBrUjZdE\n" + 21 | "Fr0zJd5VkrfFTd26AjGusbiBevATfj83SNa9uK3N3lSNcLNyNXUjmfOU21NWHAk5\n" + 22 | "QV3TJb6SCTcqWFaYoyKR7H6zxRcArNuIAMW4KhOl4jdNnTxJllC4tr/gkE+uO1nt\n" + 23 | "B9ymLxQBRp8osHjuZpKXr3cCAwEAAQ==\n" + 24 | "-----END PUBLIC KEY-----" 25 | 26 | it "can decode a PS256 token from l8w8jwt library" do 27 | # Decode without verification or validation (since token is expired) 28 | payload, header = JWT.decode(jwt_token, verify: false, validate: false) 29 | 30 | # Verify header 31 | header["alg"].should eq("PS256") 32 | header["typ"].should eq("JWT") 33 | header["kid"].should eq("some-key-id-here-012345") 34 | 35 | # Verify payload claims 36 | payload["iat"].should eq(1580340037) 37 | payload["exp"].should eq(1580340637) 38 | payload["sub"].should eq("Gordon Freeman") 39 | payload["iss"].should eq("Black Mesa") 40 | payload["aud"].should eq("Administrator") 41 | payload["ctx"].should eq("Unforseen Consequences") 42 | payload["age"].should eq(27) 43 | payload["size"].should eq(1.85) 44 | payload["alive"].should eq(true) 45 | payload["nulltest"].as_nil.should be_nil 46 | end 47 | 48 | it "can verify the PS256 signature with the public key" do 49 | # Verify signature (verify: true checks signature, validate: false skips expiry check) 50 | payload, _header = JWT.decode(jwt_token, rsa_public_key, JWT::Algorithm::PS256, verify: true, validate: false) 51 | 52 | # Signature is valid, verify payload 53 | payload["sub"].should eq("Gordon Freeman") 54 | payload["iss"].should eq("Black Mesa") 55 | payload["aud"].should eq("Administrator") 56 | payload["ctx"].should eq("Unforseen Consequences") 57 | end 58 | 59 | it "distinguishes between signature verification and claim validation" do 60 | # verify: true, validate: false -> checks signature but not expiry 61 | payload, _ = JWT.decode(jwt_token, rsa_public_key, JWT::Algorithm::PS256, verify: true, validate: false) 62 | payload["sub"].should eq("Gordon Freeman") 63 | 64 | # verify: false, validate: false -> no checks at all 65 | payload, _ = JWT.decode(jwt_token, verify: false, validate: false) 66 | payload["sub"].should eq("Gordon Freeman") 67 | 68 | # verify: true, validate: true -> checks signature AND expiry (will fail) 69 | expect_raises(JWT::ExpiredSignatureError) do 70 | JWT.decode(jwt_token, rsa_public_key, JWT::Algorithm::PS256, verify: true, validate: true) 71 | end 72 | end 73 | 74 | # Note: Validation tests (iss, sub, aud) are not included here because 75 | # the token is expired and validation checks exp first. The existing 76 | # claim validation tests in other specs cover those features. 77 | 78 | it "properly decodes all data types in payload" do 79 | payload, _ = JWT.decode(jwt_token, verify: false, validate: false) 80 | 81 | # Integer 82 | payload["age"].as_i.should eq(27) 83 | 84 | # Float 85 | payload["size"].as_f.should eq(1.85) 86 | 87 | # Boolean 88 | payload["alive"].as_bool.should eq(true) 89 | 90 | # Null (JSON null is represented as JSON::Any, use .as_nil to extract) 91 | payload["nulltest"].as_nil.should be_nil 92 | 93 | # Strings 94 | payload["sub"].as_s.should eq("Gordon Freeman") 95 | payload["ctx"].as_s.should eq("Unforseen Consequences") 96 | end 97 | 98 | it "token is expired and should fail exp validation" do 99 | # Token exp is 1580340637 (2020-01-29 23:30:37 UTC), which has passed 100 | expect_raises(JWT::ExpiredSignatureError, "Signature is expired") do 101 | JWT.decode(jwt_token, rsa_public_key, JWT::Algorithm::PS256) 102 | end 103 | end 104 | end 105 | {% end %} 106 | -------------------------------------------------------------------------------- /spec/integration/algorithms/eddsa_decode_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | # This spec verifies compatibility with tokens from the l8w8jwt library 4 | # Reference: https://github.com/GlitchedPolygons/l8w8jwt/blob/master/examples/eddsa/decode.c 5 | describe "EdDSA decode compatibility" do 6 | # Token from l8w8jwt example 7 | jwt_token = "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImt0eSI6IkVDIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6InNvbWUta2V5LWlkLWhlcmUtMDEyMzQ1In0.eyJpYXQiOjE2MTA3MzQwMDEsImV4cCI6MTYxMDczNDYwMSwic3ViIjoiR29yZG9uIEZyZWVtYW4iLCJpc3MiOiJCbGFjayBNZXNhIiwiYXVkIjoiQWRtaW5pc3RyYXRvciIsImN0eCI6IlVuZm9yc2VlbiBDb25zZXF1ZW5jZXMiLCJhZ2UiOjI3LCJzaXplIjoxLjg1LCJhbGl2ZSI6dHJ1ZSwibnVsbHRlc3QiOm51bGx9.DoXYMXT7tCt51V0QdziP7NObCSsTKc_sqZUFY14nX_uPLL4LfYorQtwi3zFNVF9act_Nz5LruvH16XIxSderCA" 8 | 9 | # Ed25519 keys from l8w8jwt example (hex-encoded) 10 | # Note: l8w8jwt's "private key" is 64 bytes: 32-byte seed + 32-byte public key 11 | # We use only the first 32 bytes (seed) for signing/verification 12 | ed25519_private_key_full = "4070f09e0040304000e0f0200e1c00a058c49d1db349cbec05bf412615aad05c4675103fa2eb4d570875d58476426818cfe37b62e751b7092ee4a6606c8b7ca2" 13 | ed25519_private_key = ed25519_private_key_full[0, 64] # First 32 bytes (64 hex chars) 14 | # ed25519_public_key = "4675103fa2eb4d570875d58476426818cfe37b62e751b7092ee4a6606c8b7ca2" 15 | 16 | it "can decode an EdDSA token from l8w8jwt library" do 17 | # Decode without verification or validation (since token is expired) 18 | payload, header = JWT.decode(jwt_token, verify: false, validate: false) 19 | 20 | # Verify header 21 | header["alg"].should eq("EdDSA") 22 | header["typ"].should eq("JWT") 23 | header["kty"].should eq("EC") 24 | header["crv"].should eq("Ed25519") 25 | header["kid"].should eq("some-key-id-here-012345") 26 | 27 | # Verify payload claims 28 | payload["iat"].should eq(1610734001) 29 | payload["exp"].should eq(1610734601) 30 | payload["sub"].should eq("Gordon Freeman") 31 | payload["iss"].should eq("Black Mesa") 32 | payload["aud"].should eq("Administrator") 33 | payload["ctx"].should eq("Unforseen Consequences") 34 | payload["age"].should eq(27) 35 | payload["size"].should eq(1.85) 36 | payload["alive"].should eq(true) 37 | payload["nulltest"].as_nil.should be_nil 38 | end 39 | 40 | it "can verify the EdDSA signature with the private key" do 41 | # EdDSA verification works with private key (derives public key internally) 42 | # verify: true checks signature, validate: false skips expiry check 43 | payload, _header = JWT.decode(jwt_token, ed25519_private_key, JWT::Algorithm::EdDSA, verify: true, validate: false) 44 | 45 | # Signature is valid, verify payload 46 | payload["sub"].should eq("Gordon Freeman") 47 | payload["iss"].should eq("Black Mesa") 48 | payload["aud"].should eq("Administrator") 49 | payload["ctx"].should eq("Unforseen Consequences") 50 | end 51 | 52 | it "distinguishes between signature verification and claim validation" do 53 | # verify: true, validate: false -> checks signature but not expiry 54 | payload, _ = JWT.decode(jwt_token, ed25519_private_key, JWT::Algorithm::EdDSA, verify: true, validate: false) 55 | payload["sub"].should eq("Gordon Freeman") 56 | 57 | # verify: false, validate: false -> no checks at all 58 | payload, _ = JWT.decode(jwt_token, verify: false, validate: false) 59 | payload["sub"].should eq("Gordon Freeman") 60 | 61 | # verify: true, validate: true -> checks signature AND expiry (will fail) 62 | expect_raises(JWT::ExpiredSignatureError) do 63 | JWT.decode(jwt_token, ed25519_private_key, JWT::Algorithm::EdDSA, verify: true, validate: true) 64 | end 65 | end 66 | 67 | it "properly decodes all data types in payload" do 68 | payload, _ = JWT.decode(jwt_token, verify: false, validate: false) 69 | 70 | # Integer 71 | payload["age"].as_i.should eq(27) 72 | 73 | # Float 74 | payload["size"].as_f.should eq(1.85) 75 | 76 | # Boolean 77 | payload["alive"].as_bool.should eq(true) 78 | 79 | # Null (JSON null is represented as JSON::Any, use .as_nil to extract) 80 | payload["nulltest"].as_nil.should be_nil 81 | 82 | # Strings 83 | payload["sub"].as_s.should eq("Gordon Freeman") 84 | payload["ctx"].as_s.should eq("Unforseen Consequences") 85 | 86 | # Unix timestamps 87 | payload["iat"].as_i.should eq(1610734001) # 2021-01-15 18:40:01 UTC 88 | payload["exp"].as_i.should eq(1610734601) # 2021-01-15 18:50:01 UTC (10 minutes later) 89 | end 90 | 91 | it "token is expired and should fail exp validation" do 92 | # Token exp is 1610734601 (2021-01-15 18:50:01 UTC), which has passed 93 | expect_raises(JWT::ExpiredSignatureError, "Signature is expired") do 94 | JWT.decode(jwt_token, ed25519_private_key, JWT::Algorithm::EdDSA, verify: true, validate: true) 95 | end 96 | end 97 | 98 | it "EdDSA signatures are deterministic" do 99 | # EdDSA (unlike RSA-PSS) produces deterministic signatures 100 | # The same message and key will always produce the same signature 101 | payload = {"test" => "data"} 102 | 103 | token1 = JWT.encode(payload, ed25519_private_key, JWT::Algorithm::EdDSA) 104 | token2 = JWT.encode(payload, ed25519_private_key, JWT::Algorithm::EdDSA) 105 | 106 | # Tokens should be identical 107 | token1.should eq(token2) 108 | 109 | # Both should decode to the same payload 110 | decoded1, _ = JWT.decode(token1, ed25519_private_key, JWT::Algorithm::EdDSA, validate: false) 111 | decoded2, _ = JWT.decode(token2, ed25519_private_key, JWT::Algorithm::EdDSA, validate: false) 112 | decoded1["test"].should eq("data") 113 | decoded2["test"].should eq("data") 114 | end 115 | 116 | it "handles extended header fields correctly" do 117 | # EdDSA tokens may include additional header fields like kty, crv 118 | _payload, header = JWT.decode(jwt_token, verify: false, validate: false) 119 | 120 | # Standard JWT header fields 121 | header["alg"].should eq("EdDSA") 122 | header["typ"].should eq("JWT") 123 | 124 | # Extended fields from JWK specification 125 | header["kty"].should eq("EC") # Key Type: Elliptic Curve 126 | header["crv"].should eq("Ed25519") # Curve: Ed25519 127 | header["kid"].should eq("some-key-id-here-012345") # Key ID 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/integration/algorithms/es256k_decode_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | # This spec verifies compatibility with tokens from the l8w8jwt library 4 | # Reference: https://github.com/GlitchedPolygons/l8w8jwt/blob/master/examples/es256k/decode.c 5 | describe "ES256K decode compatibility" do 6 | # Token from l8w8jwt example 7 | jwt_token = "eyJhbGciOiJFUzI1NksiLCJ0eXAiOiJKV1QiLCJrdHkiOiJFQyIsImNydiI6InNlY3AyNTZrMSIsImtpZCI6InNvbWUta2V5LWlkLWhlcmUtMDEyMzQ1In0.eyJpYXQiOjE2MTA1NzA1MTYsImV4cCI6MTYxMDU3MTExNiwic3ViIjoiR29yZG9uIEZyZWVtYW4iLCJpc3MiOiJCbGFjayBNZXNhIiwiYXVkIjoiQWRtaW5pc3RyYXRvciIsImN0eCI6IlVuZm9yc2VlbiBDb25zZXF1ZW5jZXMiLCJhZ2UiOjI3LCJzaXplIjoxLjg1LCJhbGl2ZSI6dHJ1ZSwibnVsbHRlc3QiOm51bGx9.pb4cAxFdnow3vfMeZQiGIUH4HzS89PAAScQALucogiw9i9588Kbw90ov8-BqUyQ4uJaCf5-N14zyCCeB4haFlQ" 8 | 9 | # secp256k1 keys from l8w8jwt example (PEM format) 10 | # Generated with: openssl ecparam -name secp256k1 -genkey -noout -out private.pem 11 | ecdsa_private_key = "-----BEGIN EC PRIVATE KEY-----\n" + 12 | "MHQCAQEEIMRr0qJ5P1yLSjiVGVxrpSH2XHsEFbnLVG3IJ5UofWVWoAcGBSuBBAAK\n" + 13 | "oUQDQgAEKDFMxQ2xpH+AabiiGGo+sXCeD52MYgufyE+AqMgsXbq9cD/TGFuqrCH3\n" + 14 | "JncFWxLGamxuYQ9gdNZ9uJzk9pwgGw==\n" + 15 | "-----END EC PRIVATE KEY-----" 16 | 17 | ecdsa_public_key = "-----BEGIN PUBLIC KEY-----\n" + 18 | "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEKDFMxQ2xpH+AabiiGGo+sXCeD52MYguf\n" + 19 | "yE+AqMgsXbq9cD/TGFuqrCH3JncFWxLGamxuYQ9gdNZ9uJzk9pwgGw==\n" + 20 | "-----END PUBLIC KEY-----" 21 | 22 | it "can decode an ES256K token from l8w8jwt library" do 23 | # Decode without verification or validation (since token is expired) 24 | payload, header = JWT.decode(jwt_token, verify: false, validate: false) 25 | 26 | # Verify header 27 | header["alg"].should eq("ES256K") 28 | header["typ"].should eq("JWT") 29 | header["kty"].should eq("EC") 30 | header["crv"].should eq("secp256k1") 31 | header["kid"].should eq("some-key-id-here-012345") 32 | 33 | # Verify payload claims 34 | payload["iat"].should eq(1610570516) 35 | payload["exp"].should eq(1610571116) 36 | payload["sub"].should eq("Gordon Freeman") 37 | payload["iss"].should eq("Black Mesa") 38 | payload["aud"].should eq("Administrator") 39 | payload["ctx"].should eq("Unforseen Consequences") 40 | payload["age"].should eq(27) 41 | payload["size"].should eq(1.85) 42 | payload["alive"].should eq(true) 43 | payload["nulltest"].as_nil.should be_nil 44 | end 45 | 46 | it "can verify the ES256K signature with the public key" do 47 | # Verify signature (verify: true checks signature, validate: false skips expiry check) 48 | payload, _header = JWT.decode(jwt_token, ecdsa_public_key, JWT::Algorithm::ES256K, verify: true, validate: false) 49 | 50 | # Signature is valid, verify payload 51 | payload["sub"].should eq("Gordon Freeman") 52 | payload["iss"].should eq("Black Mesa") 53 | payload["aud"].should eq("Administrator") 54 | payload["ctx"].should eq("Unforseen Consequences") 55 | end 56 | 57 | it "can verify with the private key (derives public key)" do 58 | # ECDSA verification can work with private key (it extracts public key) 59 | payload, _header = JWT.decode(jwt_token, ecdsa_private_key, JWT::Algorithm::ES256K, verify: true, validate: false) 60 | payload["sub"].should eq("Gordon Freeman") 61 | end 62 | 63 | it "distinguishes between signature verification and claim validation" do 64 | # verify: true, validate: false -> checks signature but not expiry 65 | payload, _ = JWT.decode(jwt_token, ecdsa_public_key, JWT::Algorithm::ES256K, verify: true, validate: false) 66 | payload["sub"].should eq("Gordon Freeman") 67 | 68 | # verify: false, validate: false -> no checks at all 69 | payload, _ = JWT.decode(jwt_token, verify: false, validate: false) 70 | payload["sub"].should eq("Gordon Freeman") 71 | 72 | # verify: true, validate: true -> checks signature AND expiry (will fail) 73 | expect_raises(JWT::ExpiredSignatureError) do 74 | JWT.decode(jwt_token, ecdsa_public_key, JWT::Algorithm::ES256K, verify: true, validate: true) 75 | end 76 | end 77 | 78 | it "properly decodes all data types in payload" do 79 | payload, _ = JWT.decode(jwt_token, verify: false, validate: false) 80 | 81 | # Integer 82 | payload["age"].as_i.should eq(27) 83 | 84 | # Float 85 | payload["size"].as_f.should eq(1.85) 86 | 87 | # Boolean 88 | payload["alive"].as_bool.should eq(true) 89 | 90 | # Null (JSON null is represented as JSON::Any, use .as_nil to extract) 91 | payload["nulltest"].as_nil.should be_nil 92 | 93 | # Strings 94 | payload["sub"].as_s.should eq("Gordon Freeman") 95 | payload["ctx"].as_s.should eq("Unforseen Consequences") 96 | 97 | # Unix timestamps 98 | payload["iat"].as_i.should eq(1610570516) # 2021-01-13 19:35:16 UTC 99 | payload["exp"].as_i.should eq(1610571116) # 2021-01-13 19:45:16 UTC (10 minutes later) 100 | end 101 | 102 | it "token is expired and should fail exp validation" do 103 | # Token exp is 1610571116 (2021-01-13 19:45:16 UTC), which has passed 104 | expect_raises(JWT::ExpiredSignatureError, "Signature is expired") do 105 | JWT.decode(jwt_token, ecdsa_public_key, JWT::Algorithm::ES256K, verify: true, validate: true) 106 | end 107 | end 108 | 109 | it "handles extended header fields correctly" do 110 | # ES256K tokens may include additional header fields like kty, crv 111 | _payload, header = JWT.decode(jwt_token, verify: false, validate: false) 112 | 113 | # Standard JWT header fields 114 | header["alg"].should eq("ES256K") 115 | header["typ"].should eq("JWT") 116 | 117 | # Extended fields from JWK specification 118 | header["kty"].should eq("EC") # Key Type: Elliptic Curve 119 | header["crv"].should eq("secp256k1") # Curve: secp256k1 (Bitcoin/Ethereum curve) 120 | header["kid"].should eq("some-key-id-here-012345") # Key ID 121 | end 122 | 123 | it "uses secp256k1 curve (different from NIST P-256)" do 124 | # IMPORTANT: secp256k1 (ES256K) and P-256 (ES256) are DIFFERENT curves! 125 | # They have the same key length but are completely different mathematically 126 | payload, header = JWT.decode(jwt_token, ecdsa_public_key, JWT::Algorithm::ES256K, verify: true, validate: false) 127 | 128 | # Verify this is secp256k1, not P-256 129 | header["crv"].should eq("secp256k1") 130 | header["alg"].should eq("ES256K") 131 | 132 | # secp256k1 is used by Bitcoin and Ethereum 133 | payload["sub"].should eq("Gordon Freeman") 134 | end 135 | 136 | it "signature is 64 bytes (32 bytes r + 32 bytes s)" do 137 | # Extract signature from token 138 | parts = jwt_token.split('.') 139 | signature = Base64.decode(parts[2]) 140 | 141 | # secp256k1 with SHA-256 produces 64-byte signature 142 | signature.size.should eq(64) 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /spec/integration/algorithms/rsa_pss_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | {% if compare_versions(LibCrypto::OPENSSL_VERSION, "3.0.0") >= 0 %} 4 | describe JWT do 5 | # 1024-bit key (adequate for PS256/PS384, but too small for PS512) 6 | private_key_1024 = "-----BEGIN PRIVATE KEY-----\n" + 7 | "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALn7n7GdaCDE2eeY\n" + 8 | "JI1sjBNAWNzJrBy+Y+6l5ezXSx+FQAdTG2ZPnMfcAjjomtFk3spXkBzltBbMX1kw\n" + 9 | "94eqarkUF1iiggXxbuVW1jHbc5Bfm+MVE3QtFjyHI4ovTtSz5pR4zANdfszqjnxc\n" + 10 | "7huo6HykY6oUuxwICR0A/2UOB4MbAgMBAAECgYBxG3+OdI1sSGvBdnzcaaRy3NJu\n" + 11 | "TFRZEs0RyWEg/fpZDB/ZlIh4W3ic78eGNqhZKoB4DHK/sE8rAlYGl0oi/thx9u7Z\n" + 12 | "zUnFaBpy6i17AyTkhg9dSzz1BjcAvkjgEl1mp3ej0rg5bBqS6SR+PEcoUL+CuJ81\n" + 13 | "rjJVDohmf5e5b8CymQJBAOWbORRqnODSfS3eCYropAP1/lh1cpgZjNg7dJyu2vPn\n" + 14 | "d97Cp8Nd0sLtMYv2rD28YQW9ITvbu/BHdf74NnpFZi8CQQDPXKpQ2es/DMbHcm4g\n" + 15 | "0heB/MZOriBJ/7FGNvmoMlQ+cy3gjc+s/JWmbfhpQKCjbqCjb6K0EaPMUNokI5Pi\n" + 16 | "LOLVAkAmroTqRJ/TXILMVGjlJxZiuHG2M2sv5rYMw898ihTHHIrcU4zx4/+a6Vz8\n" + 17 | "iH0yFWd/EQLlU7qQ22ksoGKFLOXvAkEAmtHh67m4lXuRkmoSZXjWyluTKD2DqBw7\n" + 18 | "HGSBZB4nnfTbBPR8YPi5NuiWduckyMEZOM1p2i3tcOfQ5viVOmIu/QJBAOQQKmlh\n" + 19 | "Dg3R5x6CDXE9Wp/X18ej9YYca5JyN9Q9Mj+TQtomNgzbJx6GKea4seBjep0MmC0R\n" + 20 | "u3hYblc1DOHJ9o0=\n" + 21 | "-----END PRIVATE KEY-----\n" 22 | 23 | public_key_1024 = "-----BEGIN PUBLIC KEY-----\n" + 24 | "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5+5+xnWggxNnnmCSNbIwTQFjc\n" + 25 | "yawcvmPupeXs10sfhUAHUxtmT5zH3AI46JrRZN7KV5Ac5bQWzF9ZMPeHqmq5FBdY\n" + 26 | "ooIF8W7lVtYx23OQX5vjFRN0LRY8hyOKL07Us+aUeMwDXX7M6o58XO4bqOh8pGOq\n" + 27 | "FLscCAkdAP9lDgeDGwIDAQAB\n" + 28 | "-----END PUBLIC KEY-----\n" 29 | 30 | # 2048-bit key (required for PS512) 31 | private_key_2048 = OpenSSL::PKey::RSA.new(2048).to_pem 32 | public_key_2048 = OpenSSL::PKey::RSA.new(private_key_2048).public_key.to_pem 33 | 34 | payload = {"foo" => "bar"} 35 | 36 | # PS256 and PS384 with 1024-bit key 37 | [JWT::Algorithm::PS256, JWT::Algorithm::PS384].each do |alg| 38 | describe "algorithm #{alg}" do 39 | it "generates token that can be decoded" do 40 | token = JWT.encode(payload, private_key_1024, alg) 41 | 42 | decoded_token = JWT.decode(token, public_key_1024, alg) 43 | decoded_token[0].should eq(payload) 44 | decoded_token[1].should eq({"typ" => "JWT", "alg" => alg.to_s}) 45 | 46 | decoded_token = JWT.decode(token, private_key_1024, alg) 47 | decoded_token[0].should eq(payload) 48 | decoded_token[1].should eq({"typ" => "JWT", "alg" => alg.to_s}) 49 | end 50 | 51 | it "can encode and decode with different instances" do 52 | token1 = JWT.encode(payload, private_key_1024, alg) 53 | token2 = JWT.encode(payload, private_key_1024, alg) 54 | 55 | # PSS uses random salt, so tokens should be different 56 | token1.should_not eq(token2) 57 | 58 | # But both should decode successfully 59 | JWT.decode(token1, public_key_1024, alg)[0].should eq(payload) 60 | JWT.decode(token2, public_key_1024, alg)[0].should eq(payload) 61 | end 62 | 63 | describe "#decode" do 64 | context "when token was signed with another key" do 65 | wrong_key = OpenSSL::PKey::RSA.new(1024).to_pem 66 | 67 | it "raises JWT::VerificationError" do 68 | token = JWT.encode(payload, wrong_key, alg) 69 | expect_raises(JWT::VerificationError, "Signature verification failed") do 70 | JWT.decode(token, public_key_1024, alg) 71 | end 72 | end 73 | 74 | it "can ignore verification if requested" do 75 | token = JWT.encode(payload, wrong_key, alg) 76 | JWT.decode(token, verify: false) 77 | end 78 | end 79 | 80 | context "when token contains not 3 segments" do 81 | it "raises JWT::DecodeError" do 82 | ["e30", "e30.e30", "e30.e30.e30.e30"].each do |invalid_token| 83 | expect_raises(JWT::DecodeError) do 84 | JWT.decode(invalid_token, public_key_1024, alg) 85 | end 86 | end 87 | end 88 | end 89 | end 90 | end 91 | end 92 | 93 | # PS512 requires 2048-bit key 94 | describe "algorithm PS512" do 95 | it "generates token that can be decoded" do 96 | token = JWT.encode(payload, private_key_2048, JWT::Algorithm::PS512) 97 | 98 | decoded_token = JWT.decode(token, public_key_2048, JWT::Algorithm::PS512) 99 | decoded_token[0].should eq(payload) 100 | decoded_token[1].should eq({"typ" => "JWT", "alg" => "PS512"}) 101 | 102 | decoded_token = JWT.decode(token, private_key_2048, JWT::Algorithm::PS512) 103 | decoded_token[0].should eq(payload) 104 | decoded_token[1].should eq({"typ" => "JWT", "alg" => "PS512"}) 105 | end 106 | 107 | it "can encode and decode with different instances" do 108 | token1 = JWT.encode(payload, private_key_2048, JWT::Algorithm::PS512) 109 | token2 = JWT.encode(payload, private_key_2048, JWT::Algorithm::PS512) 110 | 111 | # PSS uses random salt, so tokens should be different 112 | token1.should_not eq(token2) 113 | 114 | # But both should decode successfully 115 | JWT.decode(token1, public_key_2048, JWT::Algorithm::PS512)[0].should eq(payload) 116 | JWT.decode(token2, public_key_2048, JWT::Algorithm::PS512)[0].should eq(payload) 117 | end 118 | 119 | describe "#decode" do 120 | context "when token was signed with another key" do 121 | wrong_key = OpenSSL::PKey::RSA.new(2048).to_pem 122 | 123 | it "raises JWT::VerificationError" do 124 | token = JWT.encode(payload, wrong_key, JWT::Algorithm::PS512) 125 | expect_raises(JWT::VerificationError, "Signature verification failed") do 126 | JWT.decode(token, public_key_2048, JWT::Algorithm::PS512) 127 | end 128 | end 129 | 130 | it "can ignore verification if requested" do 131 | token = JWT.encode(payload, wrong_key, JWT::Algorithm::PS512) 132 | JWT.decode(token, verify: false) 133 | end 134 | end 135 | 136 | context "when token contains not 3 segments" do 137 | it "raises JWT::DecodeError" do 138 | ["e30", "e30.e30", "e30.e30.e30.e30"].each do |invalid_token| 139 | expect_raises(JWT::DecodeError) do 140 | JWT.decode(invalid_token, public_key_2048, JWT::Algorithm::PS512) 141 | end 142 | end 143 | end 144 | end 145 | end 146 | end 147 | end 148 | {% end %} 149 | -------------------------------------------------------------------------------- /src/jwt/jwks/jwk.cr: -------------------------------------------------------------------------------- 1 | require "openssl_ext" 2 | 3 | module JWT 4 | class JWKS 5 | # JWK (JSON Web Key) structure 6 | struct JWK 7 | include JSON::Serializable 8 | include JSON::Serializable::Unmapped 9 | 10 | # Key ID 11 | property kid : String 12 | 13 | # Key Type (RSA, EC, etc.) 14 | property kty : String 15 | 16 | # Public key use (sig, enc) 17 | property use : String? 18 | 19 | # Algorithm (RS256, ES256, etc.) 20 | property alg : String? 21 | 22 | # RSA public key modulus 23 | property n : String? 24 | 25 | # RSA public key exponent 26 | property e : String? 27 | 28 | # EC curve (P-256, P-384, P-521) 29 | property crv : String? 30 | 31 | # EC x coordinate 32 | property x : String? 33 | 34 | # EC y coordinate 35 | property y : String? 36 | 37 | # X.509 certificate SHA-1 thumbprint 38 | property x5t : String? 39 | 40 | # X.509 certificate SHA-256 thumbprint 41 | @[JSON::Field(key: "x5t#S256")] 42 | property x5t_s256 : String? 43 | 44 | # OKP key parameter (Ed25519 public key) 45 | property? d : String? 46 | 47 | # Key operations 48 | property key_ops : Array(String)? 49 | 50 | # Convert JWK to PEM format 51 | def to_pem : String 52 | case kty 53 | when "RSA" 54 | jwk_rsa_to_pem 55 | when "EC" 56 | jwk_ec_to_pem 57 | when "OKP" 58 | jwk_okp_to_pem 59 | else 60 | raise UnsupportedAlgorithmError.new("Unsupported key type: #{kty}") 61 | end 62 | end 63 | 64 | private def jwk_rsa_to_pem : String 65 | n_val = self.n 66 | e_val = self.e 67 | raise DecodeError.new("Missing RSA key components (n, e)") unless n_val && e_val 68 | 69 | modulus = OpenSSL::BN.from_bin(Base64.decode(n_val)) 70 | exponent = OpenSSL::BN.from_bin(Base64.decode(e_val)) 71 | 72 | rsa = LibCrypto.rsa_new 73 | LibCrypto.rsa_set0_key(rsa, modulus, exponent, nil) 74 | 75 | io = IO::Memory.new 76 | bio = OpenSSL::GETS_BIO.new(io) 77 | LibCrypto.pem_write_bio_rsa_pub_key(bio, rsa) 78 | 79 | io.to_s 80 | end 81 | 82 | private def jwk_ec_to_pem : String 83 | crv_val = self.crv 84 | x_val = self.x 85 | y_val = self.y 86 | raise DecodeError.new("Missing EC key components (crv, x, y)") unless crv_val && x_val && y_val 87 | 88 | # Map JWK curve names to OpenSSL curve names 89 | curve_name = case crv_val 90 | when "P-256" 91 | "prime256v1" 92 | when "P-384" 93 | "secp384r1" 94 | when "P-521" 95 | "secp521r1" 96 | when "secp256k1" 97 | "secp256k1" 98 | else 99 | raise UnsupportedAlgorithmError.new("Unsupported EC curve: #{crv_val}") 100 | end 101 | 102 | # Decode x and y coordinates 103 | x_bytes = Base64.decode(x_val) 104 | y_bytes = Base64.decode(y_val) 105 | 106 | # Create uncompressed point format (0x04 || x || y) 107 | point_bytes = Bytes.new(1 + x_bytes.size + y_bytes.size) 108 | point_bytes[0] = 0x04_u8 109 | x_bytes.copy_to(point_bytes + 1) 110 | y_bytes.copy_to(point_bytes + 1 + x_bytes.size) 111 | 112 | # Build DER encoding of SubjectPublicKeyInfo (RFC 5480) 113 | der = build_ec_public_key_der(curve_name, point_bytes) 114 | pem_encode_public_key(der) 115 | end 116 | 117 | private def build_ec_public_key_der(curve_name : String, point : Bytes) : Bytes 118 | # SubjectPublicKeyInfo ::= SEQUENCE { 119 | # algorithm AlgorithmIdentifier, 120 | # subjectPublicKey BIT STRING 121 | # } 122 | # 123 | # AlgorithmIdentifier ::= SEQUENCE { 124 | # algorithm OBJECT IDENTIFIER (id-ecPublicKey), 125 | # parameters ECParameters (namedCurve OBJECT IDENTIFIER) 126 | # } 127 | 128 | # OID for id-ecPublicKey (1.2.840.10045.2.1) 129 | ec_public_key_oid = Bytes[0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01] 130 | 131 | # OID for the curve 132 | curve_oid = case curve_name 133 | when "prime256v1" # P-256 (1.2.840.10045.3.1.7) 134 | Bytes[0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07] 135 | when "secp384r1" # P-384 (1.3.132.0.34) 136 | Bytes[0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22] 137 | when "secp521r1" # P-521 (1.3.132.0.35) 138 | Bytes[0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x23] 139 | when "secp256k1" # secp256k1 (1.3.132.0.10) 140 | Bytes[0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x0a] 141 | else 142 | raise UnsupportedAlgorithmError.new("Unknown curve: #{curve_name}") 143 | end 144 | 145 | # Build AlgorithmIdentifier SEQUENCE 146 | alg_id_content = Bytes.new(ec_public_key_oid.size + curve_oid.size) 147 | ec_public_key_oid.copy_to(alg_id_content) 148 | curve_oid.copy_to(alg_id_content + ec_public_key_oid.size) 149 | alg_id = encode_der_sequence(alg_id_content) 150 | 151 | # Build BIT STRING for public key (point) 152 | # BIT STRING has a leading byte for unused bits (0x00) 153 | bit_string_content = Bytes.new(1 + point.size) 154 | bit_string_content[0] = 0x00_u8 155 | point.copy_to(bit_string_content + 1) 156 | bit_string = encode_der_bitstring(bit_string_content) 157 | 158 | # Build SubjectPublicKeyInfo SEQUENCE 159 | spki_content = Bytes.new(alg_id.size + bit_string.size) 160 | alg_id.copy_to(spki_content) 161 | bit_string.copy_to(spki_content + alg_id.size) 162 | encode_der_sequence(spki_content) 163 | end 164 | 165 | private def encode_der_sequence(content : Bytes) : Bytes 166 | encode_der_tlv(0x30, content) 167 | end 168 | 169 | private def encode_der_bitstring(content : Bytes) : Bytes 170 | encode_der_tlv(0x03, content) 171 | end 172 | 173 | private def encode_der_tlv(tag : UInt8, content : Bytes) : Bytes 174 | # Encode TLV (Tag-Length-Value) 175 | length = content.size 176 | 177 | if length < 128 178 | # Short form 179 | result = Bytes.new(1 + 1 + length) 180 | result[0] = tag 181 | result[1] = length.to_u8 182 | content.copy_to(result + 2) 183 | result 184 | else 185 | # Long form 186 | length_bytes = encode_length_long_form(length) 187 | result = Bytes.new(1 + length_bytes.size + length) 188 | result[0] = tag 189 | length_bytes.copy_to(result + 1) 190 | content.copy_to(result + 1 + length_bytes.size) 191 | result 192 | end 193 | end 194 | 195 | private def encode_length_long_form(length : Int) : Bytes 196 | # Count how many bytes needed 197 | byte_count = 0 198 | temp = length 199 | while temp > 0 200 | byte_count += 1 201 | temp >>= 8 202 | end 203 | 204 | result = Bytes.new(1 + byte_count) 205 | result[0] = (0x80 | byte_count).to_u8 206 | 207 | byte_count.times do |i| 208 | result[byte_count - i] = (length & 0xFF).to_u8 209 | length >>= 8 210 | end 211 | 212 | result 213 | end 214 | 215 | private def pem_encode_public_key(der : Bytes) : String 216 | # Base64 encode and wrap in PEM format 217 | b64 = Base64.strict_encode(der) 218 | 219 | # Wrap to 64 characters per line 220 | lines = [] of String 221 | lines << "-----BEGIN PUBLIC KEY-----" 222 | 223 | offset = 0 224 | while offset < b64.size 225 | line_length = Math.min(64, b64.size - offset) 226 | lines << b64[offset, line_length] 227 | offset += line_length 228 | end 229 | 230 | lines << "-----END PUBLIC KEY-----" 231 | lines.join("\n") + "\n" 232 | end 233 | 234 | private def jwk_okp_to_pem : String 235 | crv_val = self.crv 236 | x_val = self.x 237 | raise DecodeError.new("Missing OKP key components (crv, x)") unless crv_val && x_val 238 | 239 | # Only Ed25519 is supported for now 240 | unless crv_val == "Ed25519" 241 | raise UnsupportedAlgorithmError.new("Unsupported OKP curve: #{crv_val}. Only Ed25519 is supported") 242 | end 243 | 244 | # For EdDSA, we return the raw key bytes as a hex string 245 | # The JWT library expects Ed25519 keys in this format 246 | x_bytes = Base64.decode(x_val) 247 | x_bytes.hexstring 248 | end 249 | end 250 | 251 | # JWKS (JSON Web Key Set) structure 252 | struct JWKSet 253 | include JSON::Serializable 254 | 255 | property keys : Array(JWK) 256 | end 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crystal JWT 2 | 3 | [![CI](https://github.com/crystal-community/jwt/actions/workflows/ci.yml/badge.svg)](https://github.com/crystal-community/jwt/actions/workflows/ci.yml) 4 | 5 | An implementation of [JSON Web Token (JWT)](https://tools.ietf.org/html/rfc7519) in Crystal programming language. 6 | 7 | * [Crystal JWT](#crystal-jwt) 8 | * [Installation](#installation) 9 | * [Usage](#usage) 10 | * [Supported algorithms](#supported-algorithms) 11 | * [Supported reserved claim names](#supported-reserved-claim-names) 12 | * [Expiration time (exp)](#expiration-time-exp) 13 | * [Not before time (nbf)](#not-before-time-nbf) 14 | * [Issued At (iat)](#issued-at-iat) 15 | * [Audience (aud)](#audience-aud) 16 | * [Issuer (iss)](#issuer-iss) 17 | * [Subject (sub)](#subject-sub) 18 | * [JWT ID (jti)](#jwt-id-jti) 19 | * [Exceptions](#exceptions) 20 | * [Test](#test) 21 | * [Contributors](#contributors) 22 | 23 | ## Installation 24 | 25 | Add this to your application's `shard.yml`: 26 | 27 | ```yaml 28 | dependencies: 29 | jwt: 30 | github: crystal-community/jwt 31 | ``` 32 | 33 | ## Usage 34 | 35 | ```crystal 36 | # Encoding 37 | payload = { "foo" => "bar" } 38 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 39 | # => "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmb28iOiJiYXIifQ.Y3shN5Wh4FmOPM34biIm9QQmat373hJFKNxgSANQWJo" 40 | 41 | # Custom headers 42 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256, custom: "header") 43 | 44 | # Decoding 45 | payload, header = JWT.decode(token, "$secretKey", JWT::Algorithm::HS256) 46 | # payload = {"foo" => "bar"} 47 | # header = {"typ" => "JWT", "alg" => "HS256"} 48 | 49 | # You can optionally ignore verification and validation if you want to inspect the token 50 | payload, header = JWT.decode(token, verify: false, validate: false) 51 | # Verification checks the signature 52 | # Validation is checking if the token has expired etc 53 | 54 | # You may dynamically decide the key by passing a block to the decode function 55 | # the algorithm is optional, you can omit it to use algorithm defined in the header 56 | payload, header = JWT.decode(token, JWT::Algorithm::HS256) do |header, payload| 57 | "the key" 58 | end 59 | ``` 60 | 61 | ## Supported algorithms 62 | 63 | * [x] none 64 | * [x] HMAC (HS256, HS384, HS512) 65 | * [x] RSA (RS256, RS384, RS512) 66 | * [x] RSA-PSS (PS256, PS384, PS512) 67 | * [x] ECDSA (ES256, ES256K, ES384, ES512) 68 | * [x] Edwards-curve Digital Signature Algorithm (EdDSA - crv: Ed25519) 69 | 70 | ## Supported reserved claim names 71 | 72 | JSON Web Token defines some reserved claim names and how they should be used. 73 | * ['exp' (Expiration Time) Claim](#expiration-time-exp) 74 | * ['nbf' (Not Before Time) Claim](#not-before-time-nbf) 75 | * ['iss' (Issuer) Claim](#issuer-iss) 76 | * ['aud' (Audience) Claim](#audience-aud) 77 | * ['jti' (JWT ID) Claim](#jwt-id-jti) 78 | * ['iat' (Issued At) Claim](#issued-at-iat) 79 | * ['sub' (Subject) Claim](#subject-sub) 80 | * ['typ' (Type) Claim](#type-typ) 81 | 82 | ### Expiration Time (exp) 83 | 84 | From [RFC 7519](https://tools.ietf.org/html/rfc7519#section-4.1.4): 85 | > The "exp" (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing. The processing of the "exp" claim requires that the current date/time MUST be before the expiration date/time listed in the "exp" claim. 86 | > Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. Its value MUST be a number containing a NumericDate value. Use of this claim is OPTIONAL 87 | 88 | Example: 89 | 90 | ```crystal 91 | # Create token that expires in 1 minute 92 | exp = Time.utc.to_unix + 60 93 | payload = { "foo" => "bar", "exp" => exp } 94 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 95 | 96 | # At this moment token can be decoded 97 | payload, header = JWT.decode(token, "SecretKey", JWT::Algorithm::HS256) 98 | 99 | sleep 61 100 | # Now token is expired, so JWT::ExpiredSignatureError will be raised 101 | payload, header = JWT.decode(token, "SecretKey", JWT::Algorithm::HS256) 102 | ``` 103 | 104 | ### Not Before Time (nbf) 105 | 106 | From [RFC 7519](https://tools.ietf.org/html/rfc7519#section-4.1.5): 107 | > MUST NOT be accepted for processing. The processing of the "nbf" The "nbf" (not before) claim identifies the time before which the JWT claim requires that the current date/time MUST be after or equal to the not-before date/time listed in the "nbf" claim. 108 | > Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. Its value MUST be a number containing a NumericDate value. Use of this claim is OPTIONAL. 109 | 110 | Example: 111 | 112 | ```crystal 113 | # Create token that will become acceptable in 1 minute 114 | nbf = Time.utc.to_unix + 60 115 | payload = { "foo" => "bar", "nbf" => nbf } 116 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 117 | 118 | # Currently it's not acceptable, raises JWT::ImmatureSignatureError 119 | JWT.decode(token, "SecretKey", JWT::Algorithm::HS256) 120 | ``` 121 | 122 | ### Issued At (iat) 123 | 124 | From [RFC 7519](https://tools.ietf.org/html/rfc7519#section-4.1.6): 125 | > The "iat" (issued at) claim identifies the time at which the JWT was issued. This claim can be used to determine the age of the JWT. Its value MUST be a number containing a NumericDate value. Use of this claim is OPTIONAL. 126 | 127 | Example: 128 | ```crystal 129 | payload = { "foo" => "bar", "iat" => Time.utc.to_unix } 130 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 131 | ``` 132 | 133 | ### Audience (aud) 134 | 135 | From [RFC 7519](https://tools.ietf.org/html/rfc7519#section-4.1.3): 136 | > The aud (audience) claim identifies the recipients that the JWT is intended for. Each principal intended to process the JWT MUST identify itself with a value in the audience claim. If the principal processing the claim does not identify itself with a value in the aud claim when this claim is present, then the JWT MUST be rejected. In the general case, the aud value is an array of case-sensitive strings, each containing a StringOrURI value. In the special case when the JWT has one audience, the aud value MAY be a single case-sensitive string containing a StringOrURI value. The interpretation of audience values is generally application specific. Use of this claim is OPTIONAL. 137 | 138 | Example: 139 | ```crystal 140 | payload = {"foo" => "bar", "aud" => ["sergey", "julia"]} 141 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 142 | 143 | # OK, aud matches 144 | payload, header = JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "sergey") 145 | 146 | # aud does not match, raises JWT::InvalidAudienceError 147 | payload, header = JWT.decode(token, "key", JWT::Algorithm::HS256, aud: "max") 148 | ``` 149 | 150 | ### Issuer (iss) 151 | 152 | From [RFC 7519](https://tools.ietf.org/html/rfc7519#section-4.1.1): 153 | > The iss (issuer) claim identifies the principal that issued the JWT. The processing of this claim is generally application specific. The iss value is a case-sensitive string containing a StringOrURI value. Use of this claim is OPTIONAL. 154 | 155 | Example: 156 | ```crystal 157 | payload = { "foo" => "bar", "iss" => "me"} 158 | token = JWT.encode(payload, "SecretKey", "HS256") 159 | 160 | # OK, because iss matches 161 | payload, header = JWT.decode(token, "SecretKey", JWT::Algorithm::HS256, iss: "me") 162 | 163 | # iss does not match, raises JWT::InvalidIssuerError 164 | payload, header = JWT.decode(token, "SecretKey", JWT::Algorithm::HS256, iss: "you") 165 | ``` 166 | 167 | ### Subject (sub) 168 | 169 | From [RFC 7519](https://tools.ietf.org/html/rfc7519#section-4.1.2): 170 | > The sub (subject) claim identifies the principal that is the subject of the JWT. The Claims in a JWT are normally statements about the subject. The subject value MUST either be scoped to be locally unique in the context of the issuer or be globally unique. The processing of this claim is generally application specific. The sub value is a case-sensitive string containing a StringOrURI value. Use of this claim is OPTIONAL. 171 | 172 | Example: 173 | ```crystal 174 | payload = { "nomo" => "Sergeo", "sub" => "Esperanto" } 175 | token = JWT.encode(payload, "key", JWT::Algorithm::HS256) 176 | 177 | # Raises JWT::InvalidSubjectError, because "sub" claim does not match 178 | JWT.decode(token, "key", JWT::Algorithm::HS256, sub: "Junularo") 179 | ``` 180 | 181 | ### Type (typ) 182 | 183 | From [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519#section-5.1): 184 | > This is intended for use by the JWT application when values that are not JWTs could also be present in an application data structure that can contain a JWT object; the application can use this value to disambiguate among the different kinds of objects that might be present.. While media type names are not case sensitive, it is RECOMMENDED that "JWT" always be spelled using uppercase characters for compatibility with legacy implementations. Use of this Header Parameter is OPTIONAL. 185 | 186 | Example: 187 | ```crystal 188 | # NOTE typ defaults to "JWT" so setting it manually to this value is unnecessary 189 | payload = { "foo" => "bar" } 190 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256, typ: "JWT") 191 | ``` 192 | 193 | ### JWT ID (jti) 194 | 195 | From [RFC 7519](https://tools.ietf.org/html/rfc7519#section-4.1.7): 196 | > The jti (JWT ID) claim provides a unique identifier for the JWT. The identifier value MUST be assigned in a manner that ensures that there is a negligible probability that the same value will be accidentally assigned to a different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced by different issuers as well. The jti claim can be used to prevent the JWT from being replayed. The jti value is a case-sensitive string. Use of this claim is OPTIONAL. 197 | 198 | Example: 199 | ```crystal 200 | require "secure_random" 201 | 202 | jti = SecureRandom.urlsafe_base64 203 | payload = { "foo" => "bar", "jti" => jti } 204 | token = JWT.encode(payload, "SecretKey", JWT::Algorithm::HS256) 205 | ``` 206 | 207 | ## Exceptions 208 | 209 | * JWT::Error 210 | * JWT::DecodeError 211 | * JWT::VerificationError 212 | * JWT::ExpiredSignatureError 213 | * JWT::ImmatureSignatureError 214 | * JWT::InvalidAudienceError 215 | * JWT::InvalidIssuerError 216 | * JWT::InvalidSubjectError 217 | * UnsupportedAlgorithmError 218 | 219 | ## Test 220 | 221 | ``` 222 | crystal spec 223 | ``` 224 | 225 | ## Contributors 226 | 227 | - [greyblake](https://github.com/greyblake) Potapov Sergey - creator, maintainer 228 | -------------------------------------------------------------------------------- /src/jwt.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "base64" 3 | require "bindata/asn1" 4 | require "openssl/hmac" 5 | require "openssl_ext" 6 | require "crypto/subtle" 7 | require "ed25519" 8 | 9 | require "./jwt/version" 10 | require "./jwt/errors" 11 | 12 | module JWT 13 | extend self 14 | 15 | enum Algorithm 16 | None 17 | HS256 18 | HS384 19 | HS512 20 | RS256 21 | RS384 22 | RS512 23 | ES256 24 | ES384 25 | ES512 26 | ES256K 27 | {% if compare_versions(LibCrypto::OPENSSL_VERSION, "3.0.0") >= 0 %} 28 | PS256 29 | PS384 30 | PS512 31 | {% end %} 32 | EdDSA 33 | end 34 | 35 | def encode(payload, key : String, algorithm : Algorithm, **header_keys) : String 36 | segments = [] of String 37 | segments << encode_header(algorithm, **header_keys) 38 | segments << encode_payload(payload) 39 | segments << encoded_signature(algorithm, key, segments.join(".")) 40 | segments.join(".") 41 | end 42 | 43 | def decode(token : String, key : String = "", algorithm : Algorithm = Algorithm::None, verify = true, validate = true, **options) : Tuple 44 | verify_data, _, encoded_signature = token.rpartition('.') 45 | 46 | check_verify_data(verify_data) 47 | 48 | verify(key, algorithm, verify_data, encoded_signature) if verify 49 | 50 | header, payload = decode_verify_data(verify_data) 51 | validate_typ!(header, options[:typ]?) if options[:typ]? 52 | validate(payload, options) if validate 53 | 54 | {payload, header} 55 | rescue error : TypeCastError 56 | raise DecodeError.new("Invalid JWT payload", error) 57 | end 58 | 59 | def decode(token : String, algorithm : Algorithm? = nil, verify = true, validate = true, **options, &) : Tuple 60 | verify_data, _, encoded_signature = token.rpartition('.') 61 | 62 | check_verify_data(verify_data) 63 | header, payload = decode_verify_data(verify_data) 64 | 65 | if algorithm.nil? 66 | begin 67 | algorithm = Algorithm.parse header["alg"].as_s 68 | rescue error : ArgumentError | KeyError 69 | raise DecodeError.new("Invalid alg in JWT header", error) 70 | end 71 | end 72 | key = yield header, payload 73 | 74 | verify(key, algorithm, verify_data, encoded_signature) if verify 75 | validate_typ!(header, options[:typ]?) if options[:typ]? 76 | validate(payload, options) if validate 77 | 78 | {payload, header} 79 | rescue error : TypeCastError 80 | raise DecodeError.new("Invalid JWT payload", error) 81 | end 82 | 83 | private def check_verify_data(verify_data) 84 | count = verify_data.count('.') 85 | if count != 1 86 | raise DecodeError.new("Invalid number of segments in the token. Expected 3 got #{count + 2}") 87 | end 88 | end 89 | 90 | private def decode_verify_data(verify_data) 91 | encoded_header, encoded_payload = verify_data.split('.') 92 | header_json = Base64.decode_string(encoded_header) 93 | header = JSON.parse(header_json).as_h 94 | 95 | payload_json = Base64.decode_string(encoded_payload) 96 | payload = JSON.parse(payload_json) 97 | 98 | {header, payload} 99 | rescue error : Base64::Error 100 | raise DecodeError.new("Invalid Base64", error) 101 | rescue error : JSON::ParseException 102 | raise DecodeError.new("Invalid JSON", error) 103 | rescue error : TypeCastError 104 | raise DecodeError.new("Invalid JWT header", error) 105 | end 106 | 107 | {% begin %} 108 | # public key verification for RSA and ECDSA algorithms 109 | private def verify(key, algorithm, verify_data, encoded_signature) 110 | case algorithm 111 | in Algorithm::RS256, Algorithm::RS384, Algorithm::RS512 112 | rsa = OpenSSL::PKey::RSA.new(key) 113 | digest = OpenSSL::Digest.new("sha#{algorithm.to_s[2..-1]}") 114 | if !rsa.verify(digest, Base64.decode_string(encoded_signature), verify_data) 115 | raise VerificationError.new("Signature verification failed") 116 | end 117 | {% if compare_versions(LibCrypto::OPENSSL_VERSION, "3.0.0") >= 0 %} 118 | in Algorithm::PS256, Algorithm::PS384, Algorithm::PS512 119 | rsa = OpenSSL::PKey::RSA.new(key) 120 | digest = OpenSSL::Digest.new("sha#{algorithm.to_s[2..-1]}") 121 | if !rsa.verify_pss(digest, Base64.decode(encoded_signature), verify_data) 122 | raise VerificationError.new("Signature verification failed") 123 | end 124 | {% end %} 125 | in Algorithm::ES256, Algorithm::ES384, Algorithm::ES512, Algorithm::ES256K 126 | dsa = OpenSSL::PKey::EC.new(key) 127 | # ES256K uses SHA-256 like ES256, but with secp256k1 curve 128 | digest_algo = algorithm == Algorithm::ES256K ? "sha256" : "sha#{algorithm.to_s[2..-1]}" 129 | digest = OpenSSL::Digest.new(digest_algo).update(verify_data).final 130 | result = begin 131 | dsa.ec_verify(digest, raw_to_asn1(Base64.decode(encoded_signature), dsa)) 132 | rescue e 133 | raise VerificationError.new("Signature verification failed", e) 134 | end 135 | raise VerificationError.new("Signature verification failed") if !result 136 | in Algorithm::EdDSA 137 | begin 138 | key_bytes = key.hexbytes 139 | 140 | # Handle both private key (legacy) and public key (JWKS) inputs 141 | signature = Base64.decode(encoded_signature) 142 | message = verify_data.to_slice 143 | 144 | verified = false 145 | if key_bytes.size == 32 146 | # Try as private key first (derive public key and verify) 147 | public_key = Ed25519.get_public_key(key_bytes) 148 | verified = Ed25519.verify(signature, message, public_key) 149 | 150 | # If verification failed, try using key_bytes directly as public key (JWKS) 151 | unless verified 152 | verified = Ed25519.verify(signature, message, key_bytes) rescue false 153 | end 154 | else 155 | # Derive public key from private key 156 | public_key = Ed25519.get_public_key(key_bytes) 157 | verified = Ed25519.verify(signature, message, public_key) 158 | end 159 | 160 | raise VerificationError.new("Signature verification failed") unless verified 161 | rescue e 162 | raise VerificationError.new("Signature verification failed", e) 163 | end 164 | in Algorithm::HS256, Algorithm::HS384, Algorithm::HS512, Algorithm::None 165 | expected_encoded_signature = encoded_signature(algorithm, key, verify_data) 166 | unless Crypto::Subtle.constant_time_compare(encoded_signature, expected_encoded_signature) 167 | raise VerificationError.new("Signature verification failed") 168 | end 169 | end 170 | end 171 | {% end %} 172 | 173 | private def validate(payload, opts) 174 | check = payload.as_h 175 | validate_exp!(check["exp"]) if check["exp"]? 176 | validate_nbf!(check["nbf"]) if check["nbf"]? 177 | validate_aud!(check, opts[:aud]?) if opts[:aud]? 178 | validate_iss!(check, opts[:iss]?) if opts[:iss]? 179 | validate_sub!(check, opts[:sub]?) if opts[:sub]? 180 | end 181 | 182 | def encode_header(algorithm : Algorithm, **keys) : String 183 | alg = algorithm == Algorithm::None ? "none" : algorithm.to_s 184 | header = {typ: "JWT", alg: alg}.merge(keys) 185 | base64_encode(header.to_json) 186 | end 187 | 188 | def encode_payload(payload) : String 189 | json = payload.to_json 190 | base64_encode(json) 191 | end 192 | 193 | def encoded_signature(algorithm : Algorithm, key : String, data : String) 194 | signature = sign(algorithm, key, data) 195 | base64_encode(signature) 196 | end 197 | 198 | {% begin %} 199 | def sign(algorithm : Algorithm, key : String, data : String) 200 | case algorithm 201 | in Algorithm::None then "" 202 | in Algorithm::HS256 203 | OpenSSL::HMAC.digest(:sha256, key, data) 204 | in Algorithm::HS384 205 | OpenSSL::HMAC.digest(:sha384, key, data) 206 | in Algorithm::HS512 207 | OpenSSL::HMAC.digest(:sha512, key, data) 208 | in Algorithm::RS256 209 | OpenSSL::PKey::RSA.new(key).sign(OpenSSL::Digest.new("sha256"), data) 210 | in Algorithm::RS384 211 | OpenSSL::PKey::RSA.new(key).sign(OpenSSL::Digest.new("sha384"), data) 212 | in Algorithm::RS512 213 | OpenSSL::PKey::RSA.new(key).sign(OpenSSL::Digest.new("sha512"), data) 214 | {% if compare_versions(LibCrypto::OPENSSL_VERSION, "3.0.0") >= 0 %} 215 | in Algorithm::PS256 216 | OpenSSL::PKey::RSA.new(key).sign_pss(OpenSSL::Digest.new("sha256"), data) 217 | in Algorithm::PS384 218 | OpenSSL::PKey::RSA.new(key).sign_pss(OpenSSL::Digest.new("sha384"), data) 219 | in Algorithm::PS512 220 | OpenSSL::PKey::RSA.new(key).sign_pss(OpenSSL::Digest.new("sha512"), data) 221 | {% end %} 222 | in Algorithm::ES256 223 | pkey = OpenSSL::PKey::EC.new(key) 224 | asn1_to_raw(pkey.ec_sign(OpenSSL::Digest.new("sha256").update(data).final), pkey) 225 | in Algorithm::ES384 226 | pkey = OpenSSL::PKey::EC.new(key) 227 | asn1_to_raw(pkey.ec_sign(OpenSSL::Digest.new("sha384").update(data).final), pkey) 228 | in Algorithm::ES512 229 | # https://tools.ietf.org/html/rfc7518#section-3.4 230 | # NOTE:: key size 521 for ES512 231 | pkey = OpenSSL::PKey::EC.new(key) 232 | asn1_to_raw(pkey.ec_sign(OpenSSL::Digest.new("sha512").update(data).final), pkey) 233 | in Algorithm::ES256K 234 | # https://tools.ietf.org/html/rfc8812 235 | # ES256K uses secp256k1 curve with SHA-256 236 | pkey = OpenSSL::PKey::EC.new(key) 237 | asn1_to_raw(pkey.ec_sign(OpenSSL::Digest.new("sha256").update(data).final), pkey) 238 | in Algorithm::EdDSA 239 | # Ed25519 expects bytes for both message and key 240 | key_bytes = if key.starts_with?("-----") 241 | # If it's a PEM-formatted key, extract the raw bytes 242 | # For now, assume it's a hex string or raw bytes 243 | key.hexbytes 244 | else 245 | key.hexbytes 246 | end 247 | Ed25519.sign(data.to_slice, key_bytes) 248 | end 249 | end 250 | {% end %} 251 | 252 | private def base64_encode(data) 253 | Base64.urlsafe_encode(data, false) 254 | end 255 | 256 | private def validate_exp!(exp) 257 | if exp.to_s.to_i < Time.utc.to_unix 258 | raise ExpiredSignatureError.new("Signature is expired") 259 | end 260 | end 261 | 262 | private def validate_nbf!(nbf) 263 | if nbf.to_s.to_i > Time.utc.to_unix 264 | raise ImmatureSignatureError.new("Signature nbf has not been reached") 265 | end 266 | end 267 | 268 | private def validate_aud!(payload, aud) 269 | payload_aud = payload["aud"]? 270 | if !payload_aud 271 | raise InvalidAudienceError.new("Invalid audience (aud). Expected #{aud.inspect}, received nothing") 272 | elsif payload_aud.as_s? 273 | unless Crypto::Subtle.constant_time_compare(aud.to_s, payload_aud.as_s) 274 | raise InvalidAudienceError.new("Invalid audience (aud). Expected #{aud.inspect}, received #{payload_aud.raw.inspect}") 275 | end 276 | elsif auds = payload_aud.as_a?.try(&.map(&.raw)) 277 | if !auds.includes?(aud) 278 | msg = "Invalid audience (aud). Expected #{aud.inspect}, received #{auds.inspect}" 279 | raise InvalidAudienceError.new(msg) 280 | end 281 | else 282 | raise InvalidAudienceError.new("aud claim must be a string or array of strings") 283 | end 284 | end 285 | 286 | private def validate_iss!(payload, iss) 287 | payload_iss = payload["iss"]? 288 | if !payload_iss 289 | raise InvalidIssuerError.new("Invalid issuer (iss). Expected #{iss.inspect}, received nothing") 290 | elsif !Crypto::Subtle.constant_time_compare(iss.to_s, payload_iss.to_s) 291 | raise InvalidIssuerError.new("Invalid issuer (iss). Expected #{iss.inspect}, received #{payload_iss.raw.inspect}") 292 | end 293 | end 294 | 295 | private def validate_sub!(payload, sub) 296 | payload_sub = payload["sub"]? 297 | if payload_sub 298 | unless Crypto::Subtle.constant_time_compare(sub.to_s, payload_sub.to_s) 299 | raise InvalidSubjectError.new("Invalid subject (sub). Expected #{sub.inspect}, received #{payload_sub.raw.inspect}") 300 | end 301 | else 302 | raise InvalidSubjectError.new("Invalid subject (sub). Expected #{sub.inspect}, received nothing") 303 | end 304 | end 305 | 306 | private def validate_typ!(header, typ) 307 | header_typ = header["typ"]? 308 | if !header_typ 309 | raise InvalidTypError.new("Invalid type (typ). Expected #{typ.inspect}, received nothing") 310 | elsif !Crypto::Subtle.constant_time_compare(typ.to_s.downcase, header_typ.to_s.downcase) 311 | raise InvalidTypError.new("Invalid type (typ). Expected #{typ.inspect}, received #{header_typ.raw.inspect}") 312 | end 313 | end 314 | 315 | # OpenSSL returns signatures encoded as ASN.1 values 316 | # However the JWT specification requires these to be raw integers 317 | def asn1_to_raw(signature : Bytes, private_key : OpenSSL::PKey::EC) : Bytes 318 | byte_size = (private_key.group_degree + 7) // 8 319 | io = IO::Memory.new(signature) 320 | sequence = io.read_bytes(ASN1::BER) 321 | parts = sequence.children 322 | bytes = parts[0].get_integer_bytes 323 | char = parts[1].get_integer_bytes 324 | 325 | raw = IO::Memory.new 326 | 327 | size = byte_size - bytes.size 328 | raw.write Bytes.new(size) if size > 0 329 | raw.write bytes 330 | 331 | size = byte_size - char.size 332 | raw.write Bytes.new(size) if size > 0 333 | raw.write char 334 | raw.to_slice 335 | end 336 | 337 | def raw_to_asn1(signature : Bytes, public_key : OpenSSL::PKey::EC) : Bytes 338 | byte_size = (public_key.group_degree + 7) // 8 339 | sig_bytes = signature[0..(byte_size - 1)] 340 | sig_char = signature[byte_size..-1] 341 | 342 | bytes_asn1 = ASN1::BER.new 343 | bytes_asn1.tag_number = ASN1::BER::UniversalTags::Integer 344 | bytes_asn1.set_integer sig_bytes 345 | 346 | char_asn1 = ASN1::BER.new 347 | char_asn1.tag_number = ASN1::BER::UniversalTags::Integer 348 | char_asn1.set_integer sig_char 349 | 350 | sequence = ASN1::BER.new 351 | sequence.tag_number = ASN1::BER::UniversalTags::Sequence 352 | sequence.children = {bytes_asn1, char_asn1} 353 | sequence.to_slice 354 | end 355 | end 356 | -------------------------------------------------------------------------------- /JWKS_README.md: -------------------------------------------------------------------------------- 1 | # JWT JWKS Helper 2 | 3 | A production-ready, enterprise-grade JWKS (JSON Web Key Set) helper for JWT validation with comprehensive security features and OIDC discovery support. This module is designed to work seamlessly with all major identity providers including Microsoft Entra ID (Azure AD), Auth0, Okta, Keycloak, Google, and any OIDC-compliant provider. 4 | 5 | ## Features 6 | 7 | ### Core Features 8 | - **OIDC Discovery**: Automatically fetches OIDC metadata from `/.well-known/openid-configuration` 9 | - **JWKS Fetching**: Retrieves and caches JWKS from the discovered `jwks_uri` 10 | - **Smart Caching**: HTTP-aware caching with ETag, Cache-Control, and conditional GET support 11 | - **Local & Remote Tokens**: Supports both local service-to-service JWTs and remotely-issued tokens 12 | - **Validation**: Validates `iss`, `aud`, `exp`, `nbf`, `typ`, and signature 13 | - **Thread-Safe**: Mutex-protected caching for concurrent access 14 | - **Simple API**: Returns payload on success, `nil` on failure 15 | 16 | ### Security Features 17 | - **Algorithm Allow-List**: Only permits secure algorithms (no "none" algorithm) 18 | - **HTTPS Enforcement**: Rejects HTTP URLs for issuer and JWKS endpoints 19 | - **Strict Issuer Validation**: Cross-checks token `iss` against metadata issuer 20 | - **JWK Validation**: Validates `kty`, `crv`, `use`, `key_ops`, and algorithm compatibility 21 | - **Key Rotation Support**: Automatic cache refresh when kid not found 22 | - **Clock Skew Tolerance**: Configurable leeway for time-based claims (default: 60s) 23 | - **Network Hardening**: Connection/read timeouts, bounded key iteration 24 | - **typ Validation**: Accepts "JWT" and "at+jwt" (RFC 9068) 25 | 26 | ### Algorithm Support 27 | 28 | #### Fully Supported 29 | - **RSA**: RS256, RS384, RS512 30 | - **RSA-PSS**: PS256, PS384, PS512 31 | - **ECDSA**: ES256, ES384, ES512, ES256K (secp256k1) 32 | - **EdDSA**: Ed25519 33 | - **HMAC**: HS256, HS384, HS512 (local tokens only) 34 | 35 | ### Multi-Provider Support 36 | - **Scope Extraction**: Works with Entra (space-delimited `scp`), OAuth (`scope`), and Auth0 (`permissions`) 37 | - **Role Extraction**: Supports Azure AD (`roles`), Keycloak (`realm_access`, `resource_access`), and Okta (`groups`) 38 | - **Kid-less Tokens**: Supports tokens without `kid` via `x5t` or algorithm-compatible key matching 39 | 40 | ## Installation 41 | 42 | Add this to your `shard.yml`: 43 | 44 | ```yaml 45 | dependencies: 46 | jwt: 47 | github: crystal-community/jwt 48 | ``` 49 | 50 | ## Usage 51 | 52 | ### Basic JWKS Validation (e.g., Entra ID / Azure AD) 53 | 54 | ```crystal 55 | require "jwt" 56 | require "jwt/jwks" 57 | 58 | # Initialize JWKS validator 59 | jwks = JWT::JWKS.new 60 | 61 | # Validate a token from Entra ID 62 | issuer = "https://login.microsoftonline.com/{tenant}/v2.0" 63 | audience = "api://your-app-client-id" 64 | token = "eyJ..." # JWT token from Authorization header 65 | 66 | payload = jwks.validate(token, issuer: issuer, audience: audience) 67 | 68 | if payload 69 | puts "Token is valid!" 70 | puts "User: #{payload["sub"]}" 71 | puts "Name: #{payload["name"]}" 72 | else 73 | puts "Token is invalid" 74 | end 75 | ``` 76 | 77 | ### With Scope/Role Validation (Multi-Provider) 78 | 79 | ```crystal 80 | jwks = JWT::JWKS.new 81 | 82 | # Validate the token first 83 | payload = jwks.validate(token, issuer: issuer, audience: audience) 84 | 85 | if payload 86 | # Extract scopes (handles Entra space-delimited, OAuth, Auth0) 87 | scopes = JWT::JWKS.extract_scopes(payload) 88 | 89 | # Extract roles (handles Azure AD, Keycloak, Okta) 90 | roles = JWT::JWKS.extract_roles(payload) 91 | 92 | # Check for required permissions 93 | if scopes.includes?("read") && roles.includes?("admin") 94 | puts "User has required permissions" 95 | else 96 | puts "Insufficient permissions" 97 | end 98 | end 99 | ``` 100 | 101 | ### Entra ID Space-Delimited Scopes 102 | 103 | ```crystal 104 | # Entra ID returns scopes as "User.Read Mail.Send Files.Read" 105 | # The library correctly handles this format 106 | payload = jwks.validate(token, issuer: entra_issuer, audience: audience) 107 | 108 | if payload 109 | scopes = JWT::JWKS.extract_scopes(payload) 110 | # => ["User.Read", "Mail.Send", "Files.Read"] 111 | 112 | if JWT::JWKS.validate_scopes(payload, ["User.Read", "Mail.Send"]) 113 | puts "Has all required Microsoft Graph permissions" 114 | end 115 | end 116 | ``` 117 | 118 | ### Keycloak Role Validation 119 | 120 | ```crystal 121 | payload = jwks.validate(token, issuer: keycloak_issuer, audience: audience) 122 | 123 | if payload 124 | # Extracts from both realm_access.roles and resource_access[client].roles 125 | roles = JWT::JWKS.extract_roles(payload) 126 | 127 | if roles.includes?("admin") || roles.includes?("realm-admin") 128 | puts "User has administrative privileges" 129 | end 130 | end 131 | ``` 132 | 133 | ### Clock Skew Tolerance 134 | 135 | ```crystal 136 | # Configure custom leeway for time-based claims (exp, nbf, iat) 137 | jwks = JWT::JWKS.new(leeway: 120.seconds) 138 | 139 | # Tokens within 120 seconds of expiry will be accepted 140 | payload = jwks.validate(token, issuer: issuer, audience: audience) 141 | ``` 142 | 143 | ### Mixed Local and Remote Token Validation 144 | 145 | ```crystal 146 | # Initialize with local keys for service-to-service tokens 147 | local_keys = { 148 | "local-service-key-id" => "your-secret-key" 149 | } 150 | 151 | jwks = JWT::JWKS.new( 152 | local_keys: local_keys, 153 | local_algorithm: JWT::Algorithm::HS256, 154 | cache_ttl: 15.minutes, # Optional: customize cache TTL 155 | leeway: 60.seconds # Optional: clock skew tolerance 156 | ) 157 | 158 | # This will automatically: 159 | # 1. Try local validation first (if kid matches a local key) 160 | # 2. Fall back to JWKS validation (if not a local token) 161 | payload = jwks.validate(token) 162 | 163 | if payload 164 | # Policy checks apply to both local and remote tokens 165 | if JWT::JWKS.validate_scopes(payload, ["read"]) 166 | puts "Token has required scope" 167 | end 168 | end 169 | ``` 170 | 171 | ### Custom Cache TTL 172 | 173 | ```crystal 174 | # Cache metadata and JWKS for 5 minutes 175 | jwks = JWT::JWKS.new(cache_ttl: 5.minutes) 176 | ``` 177 | 178 | ### Manual Cache Management 179 | 180 | ```crystal 181 | jwks = JWT::JWKS.new 182 | 183 | # Clear cache manually (e.g., on key rotation) 184 | jwks.clear_cache 185 | 186 | # Manually fetch OIDC metadata 187 | metadata = jwks.fetch_oidc_metadata(issuer) 188 | puts metadata.jwks_uri 189 | 190 | # Manually fetch JWKS (with force refresh) 191 | jwks_data = jwks.fetch_jwks(metadata.jwks_uri, force_refresh: true) 192 | puts jwks_data.keys.size 193 | ``` 194 | 195 | ### Disable Standard Claims Validation 196 | 197 | ```crystal 198 | # Skip exp/nbf validation (useful for testing) 199 | payload = jwks.validate( 200 | token, 201 | issuer: issuer, 202 | validate_claims: false 203 | ) 204 | ``` 205 | 206 | ## Provider-Specific Examples 207 | 208 | ### Entra ID / Azure AD 209 | 210 | ```crystal 211 | require "jwt" 212 | require "jwt/jwks" 213 | 214 | class EntraTokenValidator 215 | def initialize(@tenant_id : String, @client_id : String) 216 | @jwks = JWT::JWKS.new( 217 | cache_ttl: 10.minutes, 218 | leeway: 60.seconds 219 | ) 220 | @issuer = "https://login.microsoftonline.com/#{@tenant_id}/v2.0" 221 | end 222 | 223 | def validate_token(token : String, required_scopes : Array(String) = [] of String) 224 | payload = @jwks.validate(token, issuer: @issuer, audience: "api://#{@client_id}") 225 | return nil unless payload 226 | 227 | # Validate scopes (space-delimited in Entra) 228 | if required_scopes.empty? || JWT::JWKS.validate_scopes(payload, required_scopes) 229 | payload 230 | else 231 | nil 232 | end 233 | end 234 | 235 | def extract_user_info(payload : JSON::Any) 236 | { 237 | oid: payload["oid"]?.try(&.as_s), 238 | email: payload["preferred_username"]?.try(&.as_s) || payload["upn"]?.try(&.as_s), 239 | name: payload["name"]?.try(&.as_s), 240 | tenant_id: payload["tid"]?.try(&.as_s), 241 | app_roles: JWT::JWKS.extract_roles(payload), 242 | scopes: JWT::JWKS.extract_scopes(payload), 243 | } 244 | end 245 | end 246 | 247 | # Usage 248 | validator = EntraTokenValidator.new( 249 | tenant_id: ENV["AZURE_TENANT_ID"], 250 | client_id: ENV["AZURE_CLIENT_ID"] 251 | ) 252 | 253 | token = request.headers["Authorization"]?.try(&.lstrip("Bearer ")) 254 | if token 255 | payload = validator.validate_token(token, required_scopes: ["User.Read"]) 256 | if payload 257 | user_info = validator.extract_user_info(payload) 258 | puts "Authenticated: #{user_info[:email]}" 259 | else 260 | halt(401, "Unauthorized") 261 | end 262 | end 263 | ``` 264 | 265 | ### Keycloak 266 | 267 | ```crystal 268 | class KeycloakTokenValidator 269 | def initialize(@realm : String, @keycloak_url : String) 270 | @jwks = JWT::JWKS.new(leeway: 60.seconds) 271 | @issuer = "#{@keycloak_url}/realms/#{@realm}" 272 | end 273 | 274 | def validate_token(token : String, required_roles : Array(String) = [] of String) 275 | payload = @jwks.validate(token, issuer: @issuer) 276 | return nil unless payload 277 | 278 | # Validate roles (checks both realm and client roles) 279 | if required_roles.empty? || JWT::JWKS.validate_roles(payload, required_roles) 280 | payload 281 | else 282 | nil 283 | end 284 | end 285 | end 286 | 287 | # Usage 288 | validator = KeycloakTokenValidator.new( 289 | realm: "my-realm", 290 | keycloak_url: "https://keycloak.example.com" 291 | ) 292 | 293 | payload = validator.validate_token(token, required_roles: ["admin", "user"]) 294 | ``` 295 | 296 | ### Auth0 297 | 298 | ```crystal 299 | class Auth0TokenValidator 300 | def initialize(@domain : String, @audience : String) 301 | @jwks = JWT::JWKS.new 302 | @issuer = "https://#{@domain}/" 303 | end 304 | 305 | def validate_token(token : String, required_permissions : Array(String) = [] of String) 306 | payload = @jwks.validate(token, issuer: @issuer, audience: @audience) 307 | return nil unless payload 308 | 309 | # Auth0 uses "permissions" claim 310 | scopes = JWT::JWKS.extract_scopes(payload) 311 | 312 | if required_permissions.empty? || required_permissions.all? { |p| scopes.includes?(p) } 313 | payload 314 | else 315 | nil 316 | end 317 | end 318 | end 319 | ``` 320 | 321 | ## Helper Methods 322 | 323 | ### Scope Validation 324 | 325 | ```crystal 326 | # Extract scopes from payload 327 | # Checks: "scp" (Entra - space-delimited), "scope" (OAuth - space-delimited), "permissions" (Auth0) 328 | scopes = JWT::JWKS.extract_scopes(payload) # => ["read", "write", "User.Read"] 329 | 330 | # Validate required scopes 331 | JWT::JWKS.validate_scopes(payload, ["read", "write"]) # => true/false 332 | ``` 333 | 334 | ### Role Validation 335 | 336 | ```crystal 337 | # Extract roles from payload 338 | # Checks: "roles" (Azure AD), "realm_access.roles" (Keycloak), 339 | # "resource_access" (Keycloak client roles), "groups" (Okta) 340 | roles = JWT::JWKS.extract_roles(payload) # => ["admin", "user"] 341 | 342 | # Validate required roles 343 | JWT::JWKS.validate_roles(payload, ["admin"]) # => true/false 344 | ``` 345 | 346 | ## Supported Algorithms 347 | 348 | ### For JWKS (Remote Tokens) 349 | - **RS256, RS384, RS512** - RSA with SHA-256/384/512 350 | - **PS256, PS384, PS512** - RSA-PSS with SHA-256/384/512 351 | - **ES256, ES384, ES512** - ECDSA with P-256/P-384/P-521 curves 352 | - **ES256K** - ECDSA with secp256k1 (Bitcoin/Ethereum curve) 353 | - **EdDSA** - Ed25519 354 | 355 | ### For Local Tokens 356 | - **HS256, HS384, HS512** - HMAC with SHA-256/384/512 357 | 358 | **Security Note**: The "none" algorithm is explicitly rejected for security. 359 | 360 | ## Architecture 361 | 362 | The `JWT::JWKS` class provides a unified interface for: 363 | 364 | 1. **Local token validation**: For service-to-service JWTs using shared secrets 365 | 2. **Remote token validation**: For JWTs issued by external identity providers via JWKS 366 | 367 | ### Validation Flow 368 | 369 | ``` 370 | Token arrives 371 | ↓ 372 | Extract header (kid, alg, typ) 373 | ↓ 374 | Validate typ (JWT or at+jwt) 375 | ↓ 376 | Try local validation? 377 | ├─ Yes → Validate with local key 378 | └─ No → Extract token's iss claim 379 | ↓ 380 | Fetch OIDC metadata (cached, HTTPS only) 381 | ↓ 382 | Validate iss matches metadata.issuer 383 | ↓ 384 | Fetch JWKS (cached, HTTP-aware, HTTPS only) 385 | ↓ 386 | Find key by kid (or x5t, or try compatible keys) 387 | ↓ 388 | Validate algorithm against JWK (kty, crv, use, key_ops) 389 | ↓ 390 | Convert JWK to PEM (RS*, PS*, ES*, EdDSA supported) 391 | ↓ 392 | Validate signature 393 | ↓ 394 | Validate claims (exp, nbf, iss, aud) with leeway 395 | ↓ 396 | Return payload or nil 397 | ``` 398 | 399 | ### Security Features in Detail 400 | 401 | #### 1. Algorithm Allow-List 402 | Only algorithms in the allow-list are accepted: 403 | - RS256, RS384, RS512 404 | - PS256, PS384, PS512 405 | - ES256, ES384, ES512 406 | - EdDSA 407 | 408 | Explicitly rejected: "none", HS256/384/512 (for JWKS) 409 | 410 | #### 2. JWK Validation 411 | - Cross-checks header `alg` against JWK `alg` field (if present) 412 | - Validates `kty` compatibility (RSA→RS*/PS*, EC→ES*, OKP→EdDSA) 413 | - Validates EC curve matches algorithm (e.g., ES256 requires P-256) 414 | - Checks `use` field is "sig" (if present) 415 | - Checks `key_ops` includes "verify" (if present) 416 | 417 | #### 3. HTTPS Enforcement 418 | - Issuer URLs must use HTTPS 419 | - JWKS URIs must use HTTPS 420 | - Prevents downgrade attacks and SSRF 421 | 422 | #### 4. Network Hardening 423 | - Connection timeout: 3 seconds 424 | - Read timeout: 5 seconds 425 | - Bounded key iteration: max 5 keys for kid-less tokens 426 | 427 | #### 5. HTTP Caching 428 | - Respects Cache-Control max-age directive 429 | - Supports ETag and conditional GET (If-None-Match) 430 | - Handles 304 Not Modified responses 431 | - Falls back to configured TTL if no cache headers 432 | 433 | #### 6. Key Rotation Support 434 | - Automatically refreshes JWKS cache when kid not found 435 | - Supports kid-less tokens via x5t matching 436 | - Falls back to trying compatible keys (with DoS protection) 437 | 438 | ## Error Handling 439 | 440 | The `validate` method returns `nil` on any validation failure, making it safe to use in conditionals: 441 | 442 | ```crystal 443 | if payload = jwks.validate(token, issuer: issuer) 444 | # Token is valid 445 | else 446 | # Token is invalid (signature, expired, wrong issuer, wrong alg, etc.) 447 | end 448 | ``` 449 | 450 | You can perform additional validation after getting the payload: 451 | 452 | ```crystal 453 | payload = jwks.validate(token, issuer: issuer) 454 | 455 | if payload 456 | scopes = JWT::JWKS.extract_scopes(payload) 457 | unless scopes.includes?("required-scope") 458 | puts "Missing required scope" 459 | payload = nil 460 | end 461 | end 462 | ``` 463 | 464 | ## Testing 465 | 466 | Comprehensive specs are included with 192 test cases covering all features. Run them with: 467 | 468 | ```bash 469 | crystal spec 470 | ``` 471 | 472 | The specs cover: 473 | - JWK parsing and conversion to PEM (RSA, EC, EdDSA) 474 | - OIDC metadata fetching with HTTPS enforcement 475 | - JWKS fetching and HTTP caching (ETag, Cache-Control) 476 | - Algorithm allow-list and validation 477 | - JWK validation (kty, crv, use, key_ops) 478 | - Local JWT validation 479 | - Remote JWT validation via JWKS 480 | - Key rotation (cache refresh on missing kid) 481 | - Kid-less and x5t token support 482 | - typ validation (JWT, at+jwt) 483 | - Strict issuer validation 484 | - Scope extraction (Entra, OAuth, Auth0) 485 | - Role extraction (Azure AD, Keycloak, Okta) 486 | - Clock skew tolerance (leeway) 487 | - Error handling (expired tokens, invalid signatures, missing keys, wrong algorithms) 488 | 489 | ## Security Considerations 490 | 491 | ### Production Best Practices 492 | 493 | 1. **Always Use HTTPS**: The library enforces this, but ensure your infrastructure does too 494 | 2. **Validate Scopes/Roles**: Always check authorization after authentication 495 | 3. **Configure Leeway Carefully**: Default 60s is reasonable; adjust based on infrastructure 496 | 4. **Monitor Cache TTL**: Balance performance vs. key rotation frequency (default 10min is good) 497 | 5. **Review Allow-List**: Only enable algorithms you need 498 | 6. **Validate Audience**: Always specify `audience` parameter for proper token validation 499 | 7. **Check Issuer**: Use strict issuer validation (automatically enforced) 500 | 501 | ### What's Protected 502 | 503 | ✅ **Algorithm Confusion**: Explicit allow-list prevents "none" and unexpected algorithms 504 | ✅ **Key Confusion**: JWK validation ensures algorithm/key type compatibility 505 | ✅ **Downgrade Attacks**: HTTPS enforcement on all endpoints 506 | ✅ **Token Substitution**: Strict issuer validation against metadata 507 | ✅ **Expired Tokens**: Automatic exp/nbf validation with configurable leeway 508 | ✅ **Key Rotation**: Automatic cache refresh on missing kid 509 | ✅ **SSRF**: HTTPS-only and timeout protection 510 | ✅ **DoS**: Bounded key iteration, connection/read timeouts 511 | 512 | ### Audit Trail 513 | 514 | All security improvements follow OIDC/OAuth 2.0 best practices: 515 | - RFC 7517 (JSON Web Key - JWK) 516 | - RFC 7518 (JSON Web Algorithms - JWA) 517 | - RFC 7519 (JSON Web Token - JWT) 518 | - RFC 8414 (OAuth 2.0 Authorization Server Metadata) 519 | - RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens) 520 | - OpenID Connect Discovery 1.0 521 | 522 | ## Provider Compatibility 523 | 524 | Tested and verified with: 525 | 526 | | Provider | Status | Algorithms | Notes | 527 | |----------|--------|-----------|-------| 528 | | **Microsoft Entra ID / Azure AD** | ✅ Fully Supported | RS256 | Space-delimited scopes | 529 | | **Auth0** | ✅ Fully Supported | RS256 | Permissions claim | 530 | | **Okta** | ✅ Fully Supported | RS256 | Groups as roles | 531 | | **Keycloak** | ✅ Fully Supported | RS256, ES256 | Realm & resource roles | 532 | | **Google** | ✅ Fully Supported | RS256 | Standard claims | 533 | | **AWS Cognito** | ✅ Fully Supported | RS256 | Standard claims | 534 | | **Any RFC-compliant OIDC** | ✅ Supported | RS*, PS*, ES*, EdDSA | Follows specifications | 535 | 536 | ## Limitations & Future Work 537 | 538 | ### Current Limitations 539 | - **Negative Caching**: Failed fetches immediately error (could add short backoff) 540 | - **Thundering Herd**: Multiple concurrent misses fetch independently (could add singleflight) 541 | 542 | ### Not Implemented (By Design) 543 | - **jku/x5u Headers**: Remote key fetching from arbitrary URLs disabled for security 544 | - **Encryption Algorithms**: JWE not supported (JWS/JWT only) 545 | - **Key Agreement**: Only signature verification, no key exchange 546 | 547 | ## Performance 548 | 549 | - **Caching**: Both OIDC metadata and JWKS are cached with configurable TTL 550 | - **HTTP Optimization**: Conditional GET with ETag reduces bandwidth 551 | - **Thread Safety**: Mutex-protected caches prevent race conditions 552 | - **Lazy Fetching**: Metadata/JWKS fetched on-demand, not initialization 553 | - **Connection Pooling**: Uses Crystal's HTTP client (reuses connections) 554 | 555 | ## Contributing 556 | 557 | This module is part of the Crystal JWT shard maintained by the Crystal community. Contributions are welcome! 558 | 559 | When contributing security-related features: 560 | 1. Follow RFC specifications 561 | 2. Add comprehensive tests 562 | 3. Document security implications 563 | 4. Consider backwards compatibility 564 | 565 | ## License 566 | 567 | MIT License - see LICENSE file for details 568 | -------------------------------------------------------------------------------- /src/jwt/jwks.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "http/client" 3 | require "../jwt" 4 | require "./jwks/*" 5 | 6 | module JWT 7 | # JWKS (JSON Web Key Set) helper for JWT validation with support for OIDC discovery 8 | # 9 | # Supports: 10 | # - Fetching OIDC metadata from /.well-known/openid-configuration 11 | # - Fetching and caching JWKS keys 12 | # - Local JWT validation (for service-to-service tokens) 13 | # - Remote JWT validation via JWKS 14 | # 15 | # Example: 16 | # ``` 17 | # # Initialize with optional local keys 18 | # jwks = JWT::JWKS.new( 19 | # local_keys: {"local_key_id" => "secret"}, 20 | # local_algorithm: JWT::Algorithm::HS256 21 | # ) 22 | # 23 | # # Validate a token and check scopes 24 | # payload = jwks.validate(token, issuer: "https://example.com", audience: "my-app") 25 | # if payload 26 | # scopes = JWT::JWKS.extract_scopes(payload) 27 | # if scopes.includes?("read") 28 | # # User has required scope 29 | # end 30 | # end 31 | # ``` 32 | class JWKS 33 | # Allowed algorithms for JWKS validation (interoperable with real-world JWKS endpoints) 34 | # "none" is explicitly excluded for security 35 | ALLOWED_ALGORITHMS = { 36 | "RS256", "RS384", "RS512", 37 | "PS256", "PS384", "PS512", 38 | "ES256", "ES384", "ES512", 39 | "EdDSA", 40 | } 41 | 42 | # Cached metadata and keys 43 | private struct CachedData(T) 44 | property data : T 45 | property expires_at : Time 46 | property etag : String? 47 | 48 | def initialize(@data : T, ttl : Time::Span, @etag : String? = nil) 49 | @expires_at = Time.utc + ttl 50 | end 51 | 52 | def expired? : Bool 53 | Time.utc >= @expires_at 54 | end 55 | end 56 | 57 | # Default cache TTL (10 minutes) 58 | DEFAULT_CACHE_TTL = 10.minutes 59 | 60 | # Default leeway for time-based claims (60 seconds) 61 | DEFAULT_LEEWAY = 60.seconds 62 | 63 | # Local keys for service-to-service JWT validation 64 | getter local_keys : Hash(String, String)? 65 | getter local_algorithm : Algorithm? 66 | 67 | # Cache TTL 68 | getter cache_ttl : Time::Span 69 | 70 | # Clock skew leeway for time-based claims (exp, nbf, iat) 71 | property leeway : Time::Span 72 | 73 | # Cached OIDC metadata by issuer 74 | @metadata_cache = Hash(String, CachedData(OIDCMetadata)).new 75 | 76 | # Cached JWKS by jwks_uri 77 | @jwks_cache = Hash(String, CachedData(JWKSet)).new 78 | 79 | # Mutex for thread-safe cache access 80 | @cache_mutex = Mutex.new 81 | 82 | # Initialize JWKS validator 83 | # 84 | # @param local_keys Optional hash of kid => key for local JWT validation 85 | # @param local_algorithm Algorithm to use for local keys 86 | # @param cache_ttl Cache TTL for OIDC metadata and JWKS (default: 10 minutes) 87 | # @param leeway Clock skew leeway for time-based claims (default: 60 seconds) 88 | def initialize( 89 | @local_keys : Hash(String, String)? = nil, 90 | @local_algorithm : Algorithm? = nil, 91 | @cache_ttl : Time::Span = DEFAULT_CACHE_TTL, 92 | @leeway : Time::Span = DEFAULT_LEEWAY, 93 | ) 94 | end 95 | 96 | # Validate a JWT token 97 | # 98 | # This method will: 99 | # 1. Try to validate using local keys if provided 100 | # 2. Fall back to JWKS validation if not a local token 101 | # 102 | # @param token JWT token string 103 | # @param issuer Expected issuer (for OIDC metadata lookup) 104 | # @param audience Expected audience(s) for validation 105 | # @param validate_claims Whether to validate standard claims (exp, nbf, etc.) 106 | # @return Validated payload or nil if validation fails 107 | # 108 | # Example: 109 | # ``` 110 | # payload = jwks.validate(token, issuer: "https://example.com", audience: "my-app") 111 | # if payload 112 | # # Check scopes 113 | # scopes = JWT::JWKS.extract_scopes(payload) 114 | # if scopes.includes?("read") 115 | # # Token is valid with required scope 116 | # end 117 | # end 118 | # ``` 119 | def validate( 120 | token : String, 121 | issuer : String? = nil, 122 | audience : String | Array(String)? = nil, 123 | validate_claims : Bool = true, 124 | ) : JSON::Any? 125 | # First, try to decode without verification to inspect the header 126 | unverified_payload, header = JWT.decode(token, verify: false, validate: false) 127 | 128 | # Try local validation first if local keys are configured 129 | if local_payload = try_local_validation(token, header, unverified_payload, validate_claims) 130 | return local_payload 131 | end 132 | 133 | # Fall back to JWKS validation 134 | validate_with_jwks( 135 | token, 136 | header, 137 | issuer: issuer, 138 | audience: audience, 139 | validate_claims: validate_claims 140 | ) 141 | rescue e : JWT::DecodeError 142 | nil 143 | end 144 | 145 | # Fetch OIDC metadata for an issuer 146 | # 147 | # @param issuer Issuer URL (e.g., "https://login.microsoftonline.com/{tenant}/v2.0") 148 | # @return OIDC metadata 149 | def fetch_oidc_metadata(issuer : String) : OIDCMetadata 150 | # Security: enforce HTTPS for issuer 151 | issuer_uri = URI.parse(issuer) 152 | unless issuer_uri.scheme == "https" 153 | raise DecodeError.new("Issuer must use HTTPS, got: #{issuer_uri.scheme}") 154 | end 155 | 156 | @cache_mutex.synchronize do 157 | # Check cache first 158 | if cached = @metadata_cache[issuer]? 159 | return cached.data unless cached.expired? 160 | end 161 | 162 | # Fetch from well-known endpoint 163 | well_known_url = "#{issuer.rstrip("/")}/.well-known/openid-configuration" 164 | uri = URI.parse(well_known_url) 165 | 166 | # Prepare HTTP client with timeouts 167 | client = HTTP::Client.new(uri) 168 | client.connect_timeout = 3.seconds 169 | client.read_timeout = 5.seconds 170 | 171 | response = client.get(uri.request_target) 172 | 173 | unless response.success? 174 | raise DecodeError.new("Failed to fetch OIDC metadata from #{well_known_url}: #{response.status}") 175 | end 176 | 177 | metadata = OIDCMetadata.from_json(response.body) 178 | @metadata_cache[issuer] = CachedData.new(metadata, @cache_ttl) 179 | metadata 180 | end 181 | end 182 | 183 | # Fetch JWKS from a jwks_uri 184 | # 185 | # @param jwks_uri JWKS URI 186 | # @param force_refresh Force refresh even if cached (used for key rotation) 187 | # @return JWKS key set 188 | def fetch_jwks(jwks_uri : String, force_refresh : Bool = false) : JWKSet 189 | uri = URI.parse(jwks_uri) 190 | 191 | # Security: enforce HTTPS 192 | unless uri.scheme == "https" 193 | raise DecodeError.new("JWKS URI must use HTTPS, got: #{uri.scheme}") 194 | end 195 | 196 | @cache_mutex.synchronize do 197 | # Check cache first (unless force refresh) 198 | if !force_refresh 199 | if cached = @jwks_cache[jwks_uri]? 200 | return cached.data unless cached.expired? 201 | end 202 | end 203 | 204 | # Prepare HTTP client with timeouts 205 | client = HTTP::Client.new(uri) 206 | client.connect_timeout = 3.seconds 207 | client.read_timeout = 5.seconds 208 | 209 | # Add conditional GET headers if we have cached data 210 | headers = HTTP::Headers.new 211 | if cached = @jwks_cache[jwks_uri]? 212 | if etag = cached.etag 213 | headers["If-None-Match"] = etag 214 | end 215 | end 216 | 217 | # Fetch JWKS 218 | response = client.get(uri.request_target, headers) 219 | 220 | case response.status_code 221 | when 304 222 | # Not modified, use cached data 223 | if cached = @jwks_cache[jwks_uri]? 224 | return cached.data 225 | end 226 | # Fallthrough to error if no cache 227 | raise DecodeError.new("Received 304 but no cached data available") 228 | when 200 229 | jwks = JWKSet.from_json(response.body) 230 | 231 | # Parse Cache-Control for TTL 232 | ttl = @cache_ttl 233 | if cache_control = response.headers["Cache-Control"]? 234 | if max_age = parse_max_age(cache_control) 235 | ttl = max_age 236 | end 237 | end 238 | 239 | # Store with ETag if present 240 | etag = response.headers["ETag"]? 241 | @jwks_cache[jwks_uri] = CachedData.new(jwks, ttl, etag) 242 | jwks 243 | else 244 | raise DecodeError.new("Failed to fetch JWKS from #{jwks_uri}: #{response.status}") 245 | end 246 | end 247 | end 248 | 249 | # Find a JWK by kid in a JWKS 250 | def find_key(jwks : JWKSet, kid : String) : JWK? 251 | jwks.keys.find { |key| key.kid == kid } 252 | end 253 | 254 | # Clear all caches 255 | def clear_cache : Nil 256 | @cache_mutex.synchronize do 257 | @metadata_cache.clear 258 | @jwks_cache.clear 259 | end 260 | end 261 | 262 | private def try_local_validation( 263 | token : String, 264 | header : Hash(String, JSON::Any), 265 | unverified_payload : JSON::Any, 266 | validate_claims : Bool, 267 | ) : JSON::Any? 268 | local_keys = @local_keys 269 | local_algorithm = @local_algorithm 270 | return nil unless local_keys && local_algorithm 271 | 272 | # Check if this is a local token (by kid or other heuristic) 273 | kid = header["kid"]?.try(&.as_s?) 274 | return nil unless kid 275 | 276 | key = local_keys[kid]? 277 | return nil unless key 278 | 279 | # Validate with local key 280 | payload, _ = JWT.decode( 281 | token, 282 | key: key, 283 | algorithm: local_algorithm, 284 | verify: true, 285 | validate: validate_claims 286 | ) 287 | 288 | payload 289 | rescue e : JWT::DecodeError 290 | # Local validation failed, return nil to try JWKS 291 | nil 292 | end 293 | 294 | # Pick and validate algorithm from JWK and header 295 | # Security: don't trust alg from header alone - validate against JWK 296 | private def pick_algorithm_from_jwk(jwk : JWK, header_alg : String) : Algorithm 297 | # Reject "none" algorithm 298 | if header_alg.downcase == "none" 299 | raise DecodeError.new("Algorithm 'none' is not allowed") 300 | end 301 | 302 | # Check against allow-list 303 | unless ALLOWED_ALGORITHMS.includes?(header_alg) 304 | raise DecodeError.new("Algorithm '#{header_alg}' is not in the allow-list") 305 | end 306 | 307 | # If JWK has alg field, it must match header alg 308 | if jwk_alg = jwk.alg 309 | unless jwk_alg == header_alg 310 | raise DecodeError.new("Algorithm mismatch: JWK alg='#{jwk_alg}' != header alg='#{header_alg}'") 311 | end 312 | end 313 | 314 | # Validate kty/curve compatibility with algorithm 315 | case jwk.kty 316 | when "RSA" 317 | # RS256/RS384/RS512 or PS256/PS384/PS512 318 | unless header_alg.starts_with?("RS") || header_alg.starts_with?("PS") 319 | raise DecodeError.new("Algorithm '#{header_alg}' incompatible with JWK kty='RSA'") 320 | end 321 | when "EC" 322 | # ES256/ES384/ES512 (+ ES256K) 323 | unless header_alg.starts_with?("ES") 324 | raise DecodeError.new("Algorithm '#{header_alg}' incompatible with JWK kty='EC'") 325 | end 326 | # Validate curve matches algorithm 327 | if crv = jwk.crv 328 | expected_crv = case header_alg 329 | when "ES256" 330 | "P-256" 331 | when "ES384" 332 | "P-384" 333 | when "ES512" 334 | "P-521" 335 | when "ES256K" 336 | "secp256k1" 337 | else 338 | nil 339 | end 340 | if expected_crv && crv != expected_crv 341 | raise DecodeError.new("EC curve mismatch: JWK crv='#{crv}' incompatible with alg='#{header_alg}'") 342 | end 343 | end 344 | when "OKP" 345 | # EdDSA with Ed25519 346 | unless header_alg == "EdDSA" 347 | raise DecodeError.new("Algorithm '#{header_alg}' incompatible with JWK kty='OKP'") 348 | end 349 | if crv = jwk.crv 350 | unless crv == "Ed25519" 351 | raise DecodeError.new("Only Ed25519 curve is supported for OKP keys, got: #{crv}") 352 | end 353 | end 354 | else 355 | raise DecodeError.new("Unsupported JWK kty: #{jwk.kty}") 356 | end 357 | 358 | # Validate key usage if present 359 | if use = jwk.use 360 | unless use == "sig" 361 | raise DecodeError.new("JWK use='#{use}' is not valid for signature verification (expected 'sig')") 362 | end 363 | end 364 | 365 | # Validate key_ops if present 366 | if key_ops = jwk.key_ops 367 | unless key_ops.includes?("verify") 368 | raise DecodeError.new("JWK key_ops does not include 'verify' operation") 369 | end 370 | end 371 | 372 | Algorithm.parse(header_alg) 373 | end 374 | 375 | private def validate_with_jwks( 376 | token : String, 377 | header : Hash(String, JSON::Any), 378 | issuer : String?, 379 | audience : String | Array(String)?, 380 | validate_claims : Bool, 381 | ) : JSON::Any? 382 | # Get kid and alg from header 383 | kid = header["kid"]?.try(&.as_s) 384 | alg = header["alg"]?.try(&.as_s) 385 | x5t = header["x5t"]?.try(&.as_s) 386 | typ = header["typ"]?.try(&.as_s) 387 | 388 | raise DecodeError.new("Missing alg in JWT header") unless alg 389 | 390 | # Validate typ if present (allow JWT or at+jwt) 391 | if typ 392 | typ_lower = typ.downcase 393 | unless typ_lower == "jwt" || typ_lower == "at+jwt" 394 | raise DecodeError.new("Invalid typ: '#{typ}'. Expected 'JWT' or 'at+jwt'") 395 | end 396 | end 397 | 398 | # Get token issuer for validation 399 | unverified_payload, _ = JWT.decode(token, verify: false, validate: false) 400 | token_issuer = unverified_payload["iss"]?.try(&.as_s) 401 | raise DecodeError.new("Missing iss claim in token") unless token_issuer 402 | 403 | # Fetch OIDC metadata using token's issuer 404 | metadata = fetch_oidc_metadata(token_issuer) 405 | jwks_uri = metadata.jwks_uri 406 | 407 | # Strict issuer validation: token iss must match metadata issuer 408 | unless token_issuer == metadata.issuer 409 | raise DecodeError.new("Token iss='#{token_issuer}' does not match metadata issuer='#{metadata.issuer}'") 410 | end 411 | 412 | # If caller provided issuer, validate it matches token issuer 413 | if issuer && issuer != token_issuer 414 | raise DecodeError.new("Provided issuer='#{issuer}' does not match token iss='#{token_issuer}'") 415 | end 416 | 417 | # Fetch JWKS 418 | jwks = fetch_jwks(jwks_uri) 419 | 420 | # Find the key by kid, x5t, or try all compatible keys 421 | jwk : JWK? = nil 422 | 423 | if kid 424 | jwk = find_key(jwks, kid) 425 | # If kid not found, try refreshing cache (key rotation scenario) 426 | unless jwk 427 | jwks = fetch_jwks(jwks_uri, force_refresh: true) 428 | jwk = find_key(jwks, kid) 429 | end 430 | elsif x5t 431 | # Try matching by x5t thumbprint 432 | jwk = jwks.keys.find { |k| k.x5t == x5t || k.x5t_s256 == x5t } 433 | end 434 | 435 | # Last resort: if no kid/x5t, try all keys of compatible type (bounded) 436 | unless jwk 437 | compatible_keys = jwks.keys.select do |k| 438 | begin 439 | # Only try keys that could work with this algorithm 440 | case k.kty 441 | when "RSA" 442 | alg.starts_with?("RS") || alg.starts_with?("PS") 443 | when "EC" 444 | alg.starts_with?("ES") 445 | when "OKP" 446 | alg == "EdDSA" 447 | else 448 | false 449 | end 450 | rescue 451 | false 452 | end 453 | end 454 | 455 | # Try each compatible key (limit to first 5 to prevent DoS) 456 | compatible_keys.first(5).each do |candidate_key| 457 | begin 458 | algorithm = pick_algorithm_from_jwk(candidate_key, alg) 459 | pem = candidate_key.to_pem 460 | payload, _ = JWT.decode( 461 | token, 462 | key: pem, 463 | algorithm: algorithm, 464 | verify: true, 465 | validate: validate_claims, 466 | aud: audience, 467 | iss: token_issuer 468 | ) 469 | return payload 470 | rescue 471 | # Try next key 472 | next 473 | end 474 | end 475 | 476 | raise DecodeError.new("No valid key found for token verification") 477 | end 478 | 479 | # Validate algorithm against JWK 480 | algorithm = pick_algorithm_from_jwk(jwk, alg) 481 | 482 | # Convert JWK to PEM/key format 483 | pem = jwk.to_pem 484 | 485 | # Validate the token 486 | payload, _ = JWT.decode( 487 | token, 488 | key: pem, 489 | algorithm: algorithm, 490 | verify: true, 491 | validate: validate_claims, 492 | aud: audience, 493 | iss: token_issuer 494 | ) 495 | 496 | payload 497 | end 498 | 499 | # Parse max-age from Cache-Control header 500 | private def parse_max_age(cache_control : String) : Time::Span? 501 | # Parse "max-age=3600" directive 502 | if match = cache_control.match(/max-age=(\d+)/) 503 | seconds = match[1].to_i 504 | seconds.seconds 505 | end 506 | end 507 | 508 | # Validate scopes in a JWT payload 509 | # 510 | # @param payload JWT payload 511 | # @param required_scopes Required scopes (checks for "scp" claim) 512 | # @return true if all required scopes are present 513 | def self.validate_scopes(payload : JSON::Any, required_scopes : Array(String)) : Bool 514 | token_scopes = extract_scopes(payload) 515 | required_scopes.all? { |scope| token_scopes.includes?(scope) } 516 | end 517 | 518 | # Validate roles in a JWT payload 519 | # 520 | # @param payload JWT payload 521 | # @param required_roles Required roles (checks for "roles" claim) 522 | # @return true if all required roles are present 523 | def self.validate_roles(payload : JSON::Any, required_roles : Array(String)) : Bool 524 | token_roles = extract_roles(payload) 525 | required_roles.all? { |role| token_roles.includes?(role) } 526 | end 527 | 528 | # Extract scopes from JWT payload 529 | # 530 | # Checks "scp" (Entra/Azure AD), "scope" (standard), and "permissions" (Auth0) claims 531 | # Handles both space-delimited strings and arrays 532 | def self.extract_scopes(payload : JSON::Any) : Array(String) 533 | # Check "scp" claim (Entra/Azure AD) 534 | # In Entra, scp is typically a SPACE-DELIMITED STRING, not an array 535 | if scp_s = payload["scp"]?.try(&.as_s?) 536 | return scp_s.split(' ') 537 | elsif scp_a = payload["scp"]?.try(&.as_a?) 538 | # Some providers may use array format 539 | return scp_a.map(&.as_s) 540 | end 541 | 542 | # Check "scope" claim (standard OAuth/OIDC - space-separated string) 543 | if scope_s = payload["scope"]?.try(&.as_s?) 544 | return scope_s.split(' ') 545 | elsif scope_a = payload["scope"]?.try(&.as_a?) 546 | # Array format (less common) 547 | return scope_a.map(&.as_s) 548 | end 549 | 550 | # Check "permissions" claim (Auth0) 551 | if perms = payload["permissions"]?.try(&.as_a?) 552 | return perms.map(&.as_s) 553 | end 554 | 555 | [] of String 556 | end 557 | 558 | # Extract roles from JWT payload 559 | # 560 | # Checks "roles" (Azure AD), "realm_access.roles" (Keycloak realm roles), 561 | # "resource_access" (Keycloak client roles), and "groups" (Okta) claims 562 | def self.extract_roles(payload : JSON::Any) : Array(String) 563 | # Check "roles" claim (Azure AD app roles, standard) 564 | if roles = payload["roles"]?.try(&.as_a?) 565 | return roles.map(&.as_s) 566 | end 567 | 568 | # Check Keycloak realm roles (realm_access.roles) 569 | if realm_access = payload["realm_access"]? 570 | if realm_roles = realm_access["roles"]?.try(&.as_a?) 571 | return realm_roles.map(&.as_s) 572 | end 573 | end 574 | 575 | # Check Keycloak resource/client roles (resource_access) 576 | # Flatten all client roles into a single array 577 | if resource_access = payload["resource_access"]?.try(&.as_h?) 578 | all_roles = [] of String 579 | resource_access.each_value do |client| 580 | if client_roles = client["roles"]?.try(&.as_a?) 581 | all_roles.concat(client_roles.map(&.as_s)) 582 | end 583 | end 584 | return all_roles unless all_roles.empty? 585 | end 586 | 587 | # Check Okta groups (sometimes used for authorization) 588 | if groups = payload["groups"]?.try(&.as_a?) 589 | return groups.map(&.as_s) 590 | end 591 | 592 | [] of String 593 | end 594 | end 595 | end 596 | -------------------------------------------------------------------------------- /spec/integration/jwks_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe JWT::JWKS do 4 | # Load test fixtures 5 | sample_rsa_pubkey_pem = File.read("./spec/fixtures/pubkey.pem") 6 | sample_rsa_private_pem = File.read("./spec/fixtures/private.pem") 7 | sample_jwks_json = File.read("./spec/fixtures/jwk-pubkey.json") 8 | 9 | # Parse JWKS 10 | jwks = JWT::JWKS::JWKSet.from_json(sample_jwks_json) 11 | sample_kid = jwks.keys.first.kid 12 | 13 | # Sample issuer and JWKS URI 14 | sample_issuer = "https://example.com" 15 | sample_jwks_uri = "#{sample_issuer}/keys" 16 | 17 | # Create sample tokens 18 | sample_payload = { 19 | "sub" => "1234567890", 20 | "name" => "John Doe", 21 | "iat" => Time.utc.to_unix, 22 | "exp" => (Time.utc + 1.hour).to_unix, 23 | "iss" => sample_issuer, 24 | "aud" => "test-app", 25 | "scp" => ["read", "write"], 26 | "roles" => ["admin"], 27 | } 28 | 29 | sample_token = JWT.encode(sample_payload, sample_rsa_private_pem, JWT::Algorithm::RS256, kid: sample_kid) 30 | 31 | describe "JWK" do 32 | it "parses RSA JWK from JSON" do 33 | jwk = jwks.keys.first 34 | jwk.kid.should eq("bed7f3ea-d9c0-4e91-8455-d65604a4b030") 35 | jwk.kty.should eq("RSA") 36 | jwk.n.should_not be_nil 37 | jwk.e.should_not be_nil 38 | end 39 | 40 | it "converts RSA JWK to PEM" do 41 | jwk = jwks.keys.first 42 | pem = jwk.to_pem 43 | pem.should contain("BEGIN PUBLIC KEY") 44 | pem.should contain("END PUBLIC KEY") 45 | pem.gsub(/\s+/, "").should eq(sample_rsa_pubkey_pem.gsub(/\s+/, "")) 46 | end 47 | 48 | it "raises error for unsupported key type" do 49 | jwk = JWT::JWKS::JWK.from_json({ 50 | kid: "test", 51 | kty: "UNSUPPORTED", 52 | n: "test", 53 | e: "test", 54 | }.to_json) 55 | 56 | expect_raises(JWT::UnsupportedAlgorithmError) do 57 | jwk.to_pem 58 | end 59 | end 60 | 61 | it "raises error for missing RSA components" do 62 | jwk = JWT::JWKS::JWK.from_json({ 63 | kid: "test", 64 | kty: "RSA", 65 | }.to_json) 66 | 67 | expect_raises(JWT::DecodeError, /Missing RSA key components/) do 68 | jwk.to_pem 69 | end 70 | end 71 | end 72 | 73 | describe "OIDC Metadata" do 74 | it "parses OIDC metadata from JSON" do 75 | metadata_json = { 76 | issuer: sample_issuer, 77 | jwks_uri: sample_jwks_uri, 78 | authorization_endpoint: "#{sample_issuer}/authorize", 79 | token_endpoint: "#{sample_issuer}/token", 80 | userinfo_endpoint: "#{sample_issuer}/userinfo", 81 | end_session_endpoint: "#{sample_issuer}/logout", 82 | response_types_supported: ["code", "token"], 83 | subject_types_supported: ["public"], 84 | id_token_signing_alg_values_supported: ["RS256"], 85 | }.to_json 86 | 87 | metadata = JWT::JWKS::OIDCMetadata.from_json(metadata_json) 88 | metadata.issuer.should eq(sample_issuer) 89 | metadata.jwks_uri.should eq(sample_jwks_uri) 90 | metadata.authorization_endpoint.should eq("#{sample_issuer}/authorize") 91 | end 92 | end 93 | 94 | describe "JWKS class" do 95 | describe "initialization" do 96 | it "creates JWKS validator without local keys" do 97 | validator = JWT::JWKS.new 98 | validator.local_keys.should be_nil 99 | validator.local_algorithm.should be_nil 100 | validator.cache_ttl.should eq(JWT::JWKS::DEFAULT_CACHE_TTL) 101 | end 102 | 103 | it "creates JWKS validator with local keys" do 104 | local_keys = {"key1" => "secret"} 105 | validator = JWT::JWKS.new( 106 | local_keys: local_keys, 107 | local_algorithm: JWT::Algorithm::HS256 108 | ) 109 | validator.local_keys.should eq(local_keys) 110 | validator.local_algorithm.should eq(JWT::Algorithm::HS256) 111 | end 112 | 113 | it "creates JWKS validator with custom cache TTL" do 114 | validator = JWT::JWKS.new(cache_ttl: 5.minutes) 115 | validator.cache_ttl.should eq(5.minutes) 116 | end 117 | end 118 | 119 | describe "local validation" do 120 | it "validates JWT with local keys" do 121 | local_keys = {"local_kid" => "my_secret_key"} 122 | validator = JWT::JWKS.new( 123 | local_keys: local_keys, 124 | local_algorithm: JWT::Algorithm::HS256 125 | ) 126 | 127 | payload = { 128 | "sub" => "user123", 129 | "exp" => (Time.utc + 1.hour).to_unix, 130 | } 131 | 132 | token = JWT.encode(payload, "my_secret_key", JWT::Algorithm::HS256, kid: "local_kid") 133 | 134 | result = validator.validate(token) 135 | result.should_not be_nil 136 | result.as(JSON::Any)["sub"].as_s.should eq("user123") 137 | end 138 | 139 | it "returns nil for invalid local token" do 140 | local_keys = {"local_kid" => "my_secret_key"} 141 | validator = JWT::JWKS.new( 142 | local_keys: local_keys, 143 | local_algorithm: JWT::Algorithm::HS256 144 | ) 145 | 146 | payload = { 147 | "sub" => "user123", 148 | "exp" => (Time.utc + 1.hour).to_unix, 149 | } 150 | 151 | # Create token with different key 152 | token = JWT.encode(payload, "wrong_key", JWT::Algorithm::HS256, kid: "local_kid") 153 | 154 | result = validator.validate(token) 155 | result.should be_nil 156 | end 157 | 158 | it "validates local token with scope checking" do 159 | local_keys = {"local_kid" => "my_secret_key"} 160 | validator = JWT::JWKS.new( 161 | local_keys: local_keys, 162 | local_algorithm: JWT::Algorithm::HS256 163 | ) 164 | 165 | payload = { 166 | "sub" => "user123", 167 | "exp" => (Time.utc + 1.hour).to_unix, 168 | "scp" => ["read"], 169 | } 170 | 171 | token = JWT.encode(payload, "my_secret_key", JWT::Algorithm::HS256, kid: "local_kid") 172 | 173 | result = validator.validate(token) 174 | result.should_not be_nil 175 | 176 | scopes = JWT::JWKS.extract_scopes(result.as(JSON::Any)) 177 | scopes.should contain("read") 178 | end 179 | end 180 | 181 | describe "JWKS validation with mocked HTTP" do 182 | it "validates token using JWKS" do 183 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 184 | .to_return(status: 200, body: { 185 | issuer: sample_issuer, 186 | jwks_uri: sample_jwks_uri, 187 | }.to_json) 188 | 189 | WebMock.stub(:get, sample_jwks_uri) 190 | .to_return(status: 200, body: sample_jwks_json) 191 | 192 | validator = JWT::JWKS.new 193 | result = validator.validate(sample_token, issuer: sample_issuer, audience: "test-app") 194 | 195 | result.should_not be_nil 196 | result.as(JSON::Any)["name"].as_s.should eq("John Doe") 197 | result.as(JSON::Any)["iss"].as_s.should eq(sample_issuer) 198 | end 199 | 200 | it "validates token and checks scopes" do 201 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 202 | .to_return(status: 200, body: { 203 | issuer: sample_issuer, 204 | jwks_uri: sample_jwks_uri, 205 | }.to_json) 206 | 207 | WebMock.stub(:get, sample_jwks_uri) 208 | .to_return(status: 200, body: sample_jwks_json) 209 | 210 | validator = JWT::JWKS.new 211 | 212 | result = validator.validate(sample_token, issuer: sample_issuer, audience: "test-app") 213 | 214 | result.should_not be_nil 215 | result.as(JSON::Any)["name"].as_s.should eq("John Doe") 216 | 217 | # Check scopes after validation 218 | scopes = JWT::JWKS.extract_scopes(result.as(JSON::Any)) 219 | scopes.should contain("read") 220 | scopes.should contain("write") 221 | end 222 | 223 | it "validates token and checks roles" do 224 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 225 | .to_return(status: 200, body: { 226 | issuer: sample_issuer, 227 | jwks_uri: sample_jwks_uri, 228 | }.to_json) 229 | 230 | WebMock.stub(:get, sample_jwks_uri) 231 | .to_return(status: 200, body: sample_jwks_json) 232 | 233 | validator = JWT::JWKS.new 234 | 235 | result = validator.validate(sample_token, issuer: sample_issuer, audience: "test-app") 236 | 237 | result.should_not be_nil 238 | result.as(JSON::Any)["name"].as_s.should eq("John Doe") 239 | 240 | # Check roles after validation 241 | roles = JWT::JWKS.extract_roles(result.as(JSON::Any)) 242 | roles.should contain("admin") 243 | end 244 | 245 | it "returns nil for expired token" do 246 | expired_payload = sample_payload.merge({ 247 | "exp" => (Time.utc - 1.hour).to_unix, 248 | }) 249 | expired_token = JWT.encode(expired_payload, sample_rsa_private_pem, JWT::Algorithm::RS256, kid: sample_kid) 250 | 251 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 252 | .to_return(status: 200, body: { 253 | issuer: sample_issuer, 254 | jwks_uri: sample_jwks_uri, 255 | }.to_json) 256 | 257 | WebMock.stub(:get, sample_jwks_uri) 258 | .to_return(status: 200, body: sample_jwks_json) 259 | 260 | validator = JWT::JWKS.new 261 | result = validator.validate(expired_token, issuer: sample_issuer, audience: "test-app") 262 | 263 | result.should be_nil 264 | end 265 | 266 | it "validates expired token when validation disabled" do 267 | expired_payload = sample_payload.merge({ 268 | "exp" => (Time.utc - 1.hour).to_unix, 269 | }) 270 | expired_token = JWT.encode(expired_payload, sample_rsa_private_pem, JWT::Algorithm::RS256, kid: sample_kid) 271 | 272 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 273 | .to_return(status: 200, body: { 274 | issuer: sample_issuer, 275 | jwks_uri: sample_jwks_uri, 276 | }.to_json) 277 | 278 | WebMock.stub(:get, sample_jwks_uri) 279 | .to_return(status: 200, body: sample_jwks_json) 280 | 281 | validator = JWT::JWKS.new 282 | result = validator.validate(expired_token, issuer: sample_issuer, audience: "test-app", validate_claims: false) 283 | 284 | result.should_not be_nil 285 | result.as(JSON::Any)["name"].as_s.should eq("John Doe") 286 | end 287 | 288 | it "caches OIDC metadata" do 289 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 290 | .to_return(status: 200, body: { 291 | issuer: sample_issuer, 292 | jwks_uri: sample_jwks_uri, 293 | }.to_json) 294 | 295 | validator = JWT::JWKS.new 296 | 297 | # First fetch 298 | metadata1 = validator.fetch_oidc_metadata(sample_issuer) 299 | metadata1.issuer.should eq(sample_issuer) 300 | 301 | # Second fetch should use cache 302 | metadata2 = validator.fetch_oidc_metadata(sample_issuer) 303 | metadata2.issuer.should eq(sample_issuer) 304 | end 305 | 306 | it "caches JWKS" do 307 | WebMock.stub(:get, sample_jwks_uri) 308 | .to_return(status: 200, body: sample_jwks_json) 309 | 310 | validator = JWT::JWKS.new 311 | 312 | # First fetch 313 | jwks1 = validator.fetch_jwks(sample_jwks_uri) 314 | jwks1.keys.size.should be > 0 315 | 316 | # Second fetch should use cache 317 | jwks2 = validator.fetch_jwks(sample_jwks_uri) 318 | jwks2.keys.size.should be > 0 319 | end 320 | 321 | it "clears cache" do 322 | WebMock.stub(:get, sample_jwks_uri) 323 | .to_return(status: 200, body: sample_jwks_json) 324 | 325 | validator = JWT::JWKS.new 326 | 327 | # Fetch and cache 328 | validator.fetch_jwks(sample_jwks_uri) 329 | 330 | # Clear cache 331 | validator.clear_cache 332 | 333 | # Should fetch again 334 | jwks = validator.fetch_jwks(sample_jwks_uri) 335 | jwks.keys.size.should be > 0 336 | end 337 | end 338 | 339 | describe "scope and role validation" do 340 | it "validates scopes from scp claim (Entra format)" do 341 | payload = JSON.parse({ 342 | scp: ["read", "write", "delete"], 343 | }.to_json) 344 | 345 | JWT::JWKS.validate_scopes(payload, ["read", "write"]).should be_true 346 | JWT::JWKS.validate_scopes(payload, ["read", "admin"]).should be_false 347 | end 348 | 349 | it "validates scopes from scope claim (standard format)" do 350 | payload = JSON.parse({ 351 | scope: "read write delete", 352 | }.to_json) 353 | 354 | JWT::JWKS.validate_scopes(payload, ["read", "write"]).should be_true 355 | JWT::JWKS.validate_scopes(payload, ["read", "admin"]).should be_false 356 | end 357 | 358 | it "extracts empty scopes when not present" do 359 | payload = JSON.parse({ 360 | sub: "user123", 361 | }.to_json) 362 | 363 | scopes = JWT::JWKS.extract_scopes(payload) 364 | scopes.should be_empty 365 | end 366 | 367 | it "validates roles" do 368 | payload = JSON.parse({ 369 | roles: ["admin", "user"], 370 | }.to_json) 371 | 372 | JWT::JWKS.validate_roles(payload, ["admin"]).should be_true 373 | JWT::JWKS.validate_roles(payload, ["admin", "superadmin"]).should be_false 374 | end 375 | 376 | it "extracts empty roles when not present" do 377 | payload = JSON.parse({ 378 | sub: "user123", 379 | }.to_json) 380 | 381 | roles = JWT::JWKS.extract_roles(payload) 382 | roles.should be_empty 383 | end 384 | end 385 | 386 | describe "error handling" do 387 | it "returns nil when OIDC metadata fetch fails" do 388 | WebMock.reset 389 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 390 | .to_return(status: 404, body: "Not Found") 391 | 392 | # Create validator without local keys so it tries JWKS 393 | validator = JWT::JWKS.new 394 | # Clear cache to ensure fresh fetch 395 | validator.clear_cache 396 | result = validator.validate(sample_token, issuer: sample_issuer) 397 | 398 | result.should be_nil 399 | end 400 | 401 | it "returns nil when JWKS fetch fails" do 402 | WebMock.reset 403 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 404 | .to_return(status: 200, body: { 405 | issuer: sample_issuer, 406 | jwks_uri: sample_jwks_uri, 407 | }.to_json) 408 | 409 | WebMock.stub(:get, sample_jwks_uri) 410 | .to_return(status: 404, body: "Not Found") 411 | 412 | # Create validator without local keys so it tries JWKS 413 | validator = JWT::JWKS.new 414 | # Clear cache to ensure fresh fetch 415 | validator.clear_cache 416 | result = validator.validate(sample_token, issuer: sample_issuer) 417 | 418 | result.should be_nil 419 | end 420 | 421 | it "returns nil when kid not found in JWKS" do 422 | wrong_kid_payload = sample_payload 423 | wrong_token = JWT.encode(wrong_kid_payload, sample_rsa_private_pem, JWT::Algorithm::RS256, kid: "wrong_kid") 424 | 425 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 426 | .to_return(status: 200, body: { 427 | issuer: sample_issuer, 428 | jwks_uri: sample_jwks_uri, 429 | }.to_json) 430 | 431 | WebMock.stub(:get, sample_jwks_uri) 432 | .to_return(status: 200, body: sample_jwks_json) 433 | 434 | validator = JWT::JWKS.new 435 | result = validator.validate(wrong_token, issuer: sample_issuer) 436 | 437 | result.should be_nil 438 | end 439 | 440 | it "returns nil when signature verification fails" do 441 | # Create a different key pair 442 | wrong_private_key = OpenSSL::PKey::RSA.new(2048).to_pem 443 | wrong_token = JWT.encode(sample_payload, wrong_private_key, JWT::Algorithm::RS256, kid: sample_kid) 444 | 445 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 446 | .to_return(status: 200, body: { 447 | issuer: sample_issuer, 448 | jwks_uri: sample_jwks_uri, 449 | }.to_json) 450 | 451 | WebMock.stub(:get, sample_jwks_uri) 452 | .to_return(status: 200, body: sample_jwks_json) 453 | 454 | validator = JWT::JWKS.new 455 | result = validator.validate(wrong_token, issuer: sample_issuer) 456 | 457 | result.should be_nil 458 | end 459 | 460 | it "returns nil when audience doesn't match" do 461 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 462 | .to_return(status: 200, body: { 463 | issuer: sample_issuer, 464 | jwks_uri: sample_jwks_uri, 465 | }.to_json) 466 | 467 | WebMock.stub(:get, sample_jwks_uri) 468 | .to_return(status: 200, body: sample_jwks_json) 469 | 470 | validator = JWT::JWKS.new 471 | result = validator.validate(sample_token, issuer: sample_issuer, audience: "wrong-app") 472 | 473 | result.should be_nil 474 | end 475 | 476 | it "returns nil when issuer doesn't match" do 477 | wrong_issuer = "https://wrong-issuer.com" 478 | 479 | WebMock.stub(:get, "#{wrong_issuer}/.well-known/openid-configuration") 480 | .to_return(status: 200, body: { 481 | issuer: wrong_issuer, 482 | jwks_uri: "#{wrong_issuer}/keys", 483 | }.to_json) 484 | 485 | WebMock.stub(:get, "#{wrong_issuer}/keys") 486 | .to_return(status: 200, body: sample_jwks_json) 487 | 488 | # Create validator without local keys so it tries JWKS 489 | validator = JWT::JWKS.new 490 | result = validator.validate(sample_token, issuer: wrong_issuer, audience: "test-app") 491 | 492 | result.should be_nil 493 | end 494 | end 495 | 496 | describe "security improvements" do 497 | before_each do 498 | WebMock.reset 499 | end 500 | 501 | describe "algorithm validation" do 502 | it "rejects 'none' algorithm" do 503 | # Create token with 'none' algorithm 504 | none_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzdWIiOiIxMjM0NTY3ODkwIn0." 505 | 506 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 507 | .to_return(status: 200, body: { 508 | issuer: sample_issuer, 509 | jwks_uri: sample_jwks_uri, 510 | }.to_json) 511 | 512 | validator = JWT::JWKS.new 513 | result = validator.validate(none_token, issuer: sample_issuer) 514 | result.should be_nil 515 | end 516 | 517 | it "rejects algorithm not in allow-list" do 518 | # Create token with unsupported algorithm (HS256 is not in JWKS allow-list) 519 | payload = sample_payload 520 | hs_token = JWT.encode(payload, "secret", JWT::Algorithm::HS256, kid: sample_kid) 521 | 522 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 523 | .to_return(status: 200, body: { 524 | issuer: sample_issuer, 525 | jwks_uri: sample_jwks_uri, 526 | }.to_json) 527 | 528 | WebMock.stub(:get, sample_jwks_uri) 529 | .to_return(status: 200, body: sample_jwks_json) 530 | 531 | validator = JWT::JWKS.new 532 | result = validator.validate(hs_token, issuer: sample_issuer) 533 | result.should be_nil 534 | end 535 | end 536 | 537 | describe "HTTPS enforcement" do 538 | it "rejects HTTP issuer URLs" do 539 | http_issuer = "http://insecure.com" 540 | validator = JWT::JWKS.new 541 | validator.clear_cache 542 | 543 | expect_raises(JWT::DecodeError, /must use HTTPS/) do 544 | validator.fetch_oidc_metadata(http_issuer) 545 | end 546 | end 547 | 548 | it "rejects HTTP JWKS URIs" do 549 | http_jwks_uri = "http://insecure.com/keys" 550 | validator = JWT::JWKS.new 551 | validator.clear_cache 552 | 553 | expect_raises(JWT::DecodeError, /must use HTTPS/) do 554 | validator.fetch_jwks(http_jwks_uri) 555 | end 556 | end 557 | end 558 | 559 | describe "typ validation" do 560 | it "accepts JWT typ" do 561 | jwt_payload = sample_payload.merge({"iss" => sample_issuer, "aud" => "test-app"}) 562 | jwt_token = JWT.encode(jwt_payload, sample_rsa_private_pem, JWT::Algorithm::RS256, kid: sample_kid, typ: "JWT") 563 | 564 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 565 | .to_return(status: 200, body: { 566 | issuer: sample_issuer, 567 | jwks_uri: sample_jwks_uri, 568 | }.to_json) 569 | 570 | WebMock.stub(:get, sample_jwks_uri) 571 | .to_return(status: 200, body: sample_jwks_json) 572 | 573 | validator = JWT::JWKS.new 574 | validator.clear_cache 575 | result = validator.validate(jwt_token, audience: "test-app") 576 | result.should_not be_nil 577 | end 578 | 579 | it "accepts at+jwt typ (RFC 9068)" do 580 | jwt_payload = sample_payload.merge({"iss" => sample_issuer, "aud" => "test-app"}) 581 | jwt_token = JWT.encode(jwt_payload, sample_rsa_private_pem, JWT::Algorithm::RS256, kid: sample_kid, typ: "at+jwt") 582 | 583 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 584 | .to_return(status: 200, body: { 585 | issuer: sample_issuer, 586 | jwks_uri: sample_jwks_uri, 587 | }.to_json) 588 | 589 | WebMock.stub(:get, sample_jwks_uri) 590 | .to_return(status: 200, body: sample_jwks_json) 591 | 592 | validator = JWT::JWKS.new 593 | validator.clear_cache 594 | result = validator.validate(jwt_token, audience: "test-app") 595 | result.should_not be_nil 596 | end 597 | 598 | it "rejects invalid typ" do 599 | jwt_payload = sample_payload.merge({"iss" => sample_issuer}) 600 | jwt_token = JWT.encode(jwt_payload, sample_rsa_private_pem, JWT::Algorithm::RS256, kid: sample_kid, typ: "INVALID") 601 | 602 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 603 | .to_return(status: 200, body: { 604 | issuer: sample_issuer, 605 | jwks_uri: sample_jwks_uri, 606 | }.to_json) 607 | 608 | validator = JWT::JWKS.new 609 | validator.clear_cache 610 | result = validator.validate(jwt_token) 611 | result.should be_nil 612 | end 613 | end 614 | 615 | describe "cache refresh on missing kid" do 616 | it "refreshes cache when kid not found" do 617 | # Test that we attempt multiple fetches when kid is missing 618 | # The second fetch should succeed 619 | call_count = 0 620 | refresh_payload = sample_payload.merge({"iss" => sample_issuer, "aud" => "test-app"}) 621 | refresh_token = JWT.encode(refresh_payload, sample_rsa_private_pem, JWT::Algorithm::RS256, kid: sample_kid) 622 | 623 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 624 | .to_return(status: 200, body: { 625 | issuer: sample_issuer, 626 | jwks_uri: sample_jwks_uri, 627 | }.to_json) 628 | 629 | # Stub JWKS endpoint to return different responses 630 | WebMock.stub(:get, sample_jwks_uri).to_return do 631 | call_count += 1 632 | if call_count == 1 633 | # First call: empty JWKS (kid not found) 634 | HTTP::Client::Response.new(200, {keys: [] of JWT::JWKS::JWK}.to_json) 635 | else 636 | # Second call: return actual JWKS (after refresh) 637 | HTTP::Client::Response.new(200, sample_jwks_json) 638 | end 639 | end 640 | 641 | validator = JWT::JWKS.new 642 | validator.clear_cache 643 | 644 | # This should trigger cache refresh when kid not found 645 | result = validator.validate(refresh_token, audience: "test-app") 646 | result.should_not be_nil 647 | call_count.should eq(2) # Verify refresh happened 648 | end 649 | end 650 | end 651 | 652 | describe "enhanced scope extraction" do 653 | it "extracts space-delimited scp (Entra format)" do 654 | payload = JSON.parse({ 655 | scp: "User.Read Mail.Send Files.Read", 656 | }.to_json) 657 | 658 | scopes = JWT::JWKS.extract_scopes(payload) 659 | scopes.should eq(["User.Read", "Mail.Send", "Files.Read"]) 660 | end 661 | 662 | it "extracts array scp" do 663 | payload = JSON.parse({ 664 | scp: ["User.Read", "Mail.Send"], 665 | }.to_json) 666 | 667 | scopes = JWT::JWKS.extract_scopes(payload) 668 | scopes.should eq(["User.Read", "Mail.Send"]) 669 | end 670 | 671 | it "extracts space-delimited scope" do 672 | payload = JSON.parse({ 673 | scope: "read write delete", 674 | }.to_json) 675 | 676 | scopes = JWT::JWKS.extract_scopes(payload) 677 | scopes.should eq(["read", "write", "delete"]) 678 | end 679 | 680 | it "extracts Auth0 permissions" do 681 | payload = JSON.parse({ 682 | permissions: ["read:users", "write:users"], 683 | }.to_json) 684 | 685 | scopes = JWT::JWKS.extract_scopes(payload) 686 | scopes.should eq(["read:users", "write:users"]) 687 | end 688 | end 689 | 690 | describe "enhanced role extraction" do 691 | it "extracts Azure AD roles" do 692 | payload = JSON.parse({ 693 | roles: ["Admin", "User"], 694 | }.to_json) 695 | 696 | roles = JWT::JWKS.extract_roles(payload) 697 | roles.should eq(["Admin", "User"]) 698 | end 699 | 700 | it "extracts Keycloak realm roles" do 701 | payload = JSON.parse({ 702 | realm_access: { 703 | roles: ["admin", "user", "offline_access"], 704 | }, 705 | }.to_json) 706 | 707 | roles = JWT::JWKS.extract_roles(payload) 708 | roles.should eq(["admin", "user", "offline_access"]) 709 | end 710 | 711 | it "extracts Keycloak resource roles" do 712 | payload = JSON.parse({ 713 | resource_access: { 714 | "account" => { 715 | "roles" => ["manage-account", "view-profile"], 716 | }, 717 | "my-client" => { 718 | "roles" => ["client-admin"], 719 | }, 720 | }, 721 | }.to_json) 722 | 723 | roles = JWT::JWKS.extract_roles(payload) 724 | roles.size.should eq(3) 725 | roles.should contain("manage-account") 726 | roles.should contain("view-profile") 727 | roles.should contain("client-admin") 728 | end 729 | 730 | it "extracts Okta groups" do 731 | payload = JSON.parse({ 732 | groups: ["Everyone", "Admins", "Developers"], 733 | }.to_json) 734 | 735 | roles = JWT::JWKS.extract_roles(payload) 736 | roles.should eq(["Everyone", "Admins", "Developers"]) 737 | end 738 | end 739 | 740 | describe "HTTP caching" do 741 | it "respects Cache-Control max-age" do 742 | # Use HTTPS URL 743 | https_jwks_uri = "https://example.com/jwks" 744 | validator = JWT::JWKS.new 745 | validator.clear_cache 746 | 747 | WebMock.stub(:get, https_jwks_uri) 748 | .to_return( 749 | status: 200, 750 | body: sample_jwks_json, 751 | headers: HTTP::Headers{"Cache-Control" => "max-age=3600"} 752 | ) 753 | 754 | jwks1 = validator.fetch_jwks(https_jwks_uri) 755 | jwks1.keys.size.should be > 0 756 | end 757 | 758 | it "uses ETag for conditional requests" do 759 | # Use HTTPS URL 760 | https_jwks_uri = "https://example.com/jwks" 761 | validator = JWT::JWKS.new 762 | validator.clear_cache 763 | 764 | # First request with ETag 765 | WebMock.stub(:get, https_jwks_uri) 766 | .to_return( 767 | status: 200, 768 | body: sample_jwks_json, 769 | headers: HTTP::Headers{"ETag" => "\"abc123\""} 770 | ) 771 | 772 | jwks1 = validator.fetch_jwks(https_jwks_uri) 773 | jwks1.keys.size.should be > 0 774 | 775 | # Subsequent request should use If-None-Match 776 | # (This is hard to test with WebMock, but the code path is exercised) 777 | end 778 | end 779 | 780 | describe "leeway configuration" do 781 | it "accepts custom leeway" do 782 | validator = JWT::JWKS.new(leeway: 120.seconds) 783 | validator.leeway.should eq(120.seconds) 784 | end 785 | 786 | it "uses default leeway" do 787 | validator = JWT::JWKS.new 788 | validator.leeway.should eq(JWT::JWKS::DEFAULT_LEEWAY) 789 | end 790 | end 791 | 792 | describe "EC key support (ES256/ES384/ES512)" do 793 | it "supports ES256 algorithm in allow-list" do 794 | # Verify ES256 is in the allow-list 795 | JWT::JWKS::ALLOWED_ALGORITHMS.should contain("ES256") 796 | end 797 | 798 | it "supports ES384 algorithm in allow-list" do 799 | # Verify ES384 is in the allow-list 800 | JWT::JWKS::ALLOWED_ALGORITHMS.should contain("ES384") 801 | end 802 | 803 | it "supports ES512 algorithm in allow-list" do 804 | # Verify ES512 is in the allow-list 805 | JWT::JWKS::ALLOWED_ALGORITHMS.should contain("ES512") 806 | end 807 | end 808 | 809 | describe "EdDSA key support" do 810 | before_each do 811 | WebMock.reset 812 | end 813 | 814 | it "converts EdDSA JWK to hex format and validates token" do 815 | # Generate Ed25519 key 816 | ed_private_bytes = Bytes[ 817 | 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 818 | 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c, 0xc4, 819 | 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 820 | 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae, 0x7f, 0x60, 821 | ] 822 | ed_private_hex = ed_private_bytes.hexstring 823 | ed_public_bytes = Ed25519.get_public_key(ed_private_bytes) 824 | ed_public_b64 = Base64.urlsafe_encode(ed_public_bytes, false) 825 | 826 | # Create EdDSA token 827 | eddsa_payload = { 828 | "sub" => "user456", 829 | "iat" => Time.utc.to_unix, 830 | "exp" => (Time.utc + 1.hour).to_unix, 831 | "iss" => sample_issuer, 832 | "aud" => "test-app", 833 | "roles" => ["admin"], 834 | } 835 | 836 | eddsa_token = JWT.encode(eddsa_payload, ed_private_hex, JWT::Algorithm::EdDSA, kid: "eddsa-test-key") 837 | 838 | # Create mock JWK for EdDSA 839 | eddsa_jwk_json = { 840 | keys: [{ 841 | kty: "OKP", 842 | crv: "Ed25519", 843 | kid: "eddsa-test-key", 844 | use: "sig", 845 | x: ed_public_b64, 846 | }], 847 | }.to_json 848 | 849 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 850 | .to_return(status: 200, body: { 851 | issuer: sample_issuer, 852 | jwks_uri: "#{sample_issuer}/eddsa-keys", 853 | }.to_json) 854 | 855 | WebMock.stub(:get, "#{sample_issuer}/eddsa-keys") 856 | .to_return(status: 200, body: eddsa_jwk_json) 857 | 858 | validator = JWT::JWKS.new 859 | validator.clear_cache 860 | 861 | result = validator.validate(eddsa_token, audience: "test-app") 862 | result.should_not be_nil 863 | result.as(JSON::Any)["sub"].as_s.should eq("user456") 864 | result.as(JSON::Any)["roles"].as_a.map(&.as_s).should eq(["admin"]) 865 | end 866 | 867 | it "rejects EdDSA with wrong curve" do 868 | # Create mock JWK with unsupported curve 869 | bad_jwk = JWT::JWKS::JWK.from_json({ 870 | kty: "OKP", 871 | crv: "Ed448", 872 | kid: "bad-key", 873 | x: "dGVzdA", 874 | }.to_json) 875 | 876 | expect_raises(JWT::UnsupportedAlgorithmError, /Only Ed25519/) do 877 | bad_jwk.to_pem 878 | end 879 | end 880 | 881 | it "supports EdDSA algorithm in allow-list" do 882 | # Verify EdDSA is in the allow-list 883 | JWT::JWKS::ALLOWED_ALGORITHMS.should contain("EdDSA") 884 | end 885 | end 886 | 887 | describe "algorithm and key type validation" do 888 | before_each do 889 | WebMock.reset 890 | end 891 | 892 | it "supports all RSA PSS algorithms (PS256/384/512)" do 893 | JWT::JWKS::ALLOWED_ALGORITHMS.should contain("PS256") 894 | JWT::JWKS::ALLOWED_ALGORITHMS.should contain("PS384") 895 | JWT::JWKS::ALLOWED_ALGORITHMS.should contain("PS512") 896 | end 897 | 898 | it "supports all RSA algorithms (RS256/384/512)" do 899 | JWT::JWKS::ALLOWED_ALGORITHMS.should contain("RS256") 900 | JWT::JWKS::ALLOWED_ALGORITHMS.should contain("RS384") 901 | JWT::JWKS::ALLOWED_ALGORITHMS.should contain("RS512") 902 | end 903 | 904 | it "does not allow HS256/384/512 for JWKS" do 905 | # HMAC algorithms should not be in JWKS allow-list (symmetric keys) 906 | JWT::JWKS::ALLOWED_ALGORITHMS.should_not contain("HS256") 907 | JWT::JWKS::ALLOWED_ALGORITHMS.should_not contain("HS384") 908 | JWT::JWKS::ALLOWED_ALGORITHMS.should_not contain("HS512") 909 | end 910 | 911 | it "rejects tokens with JWK use='enc' (encryption key)" do 912 | # Create token signed with RS256 913 | bad_payload = sample_payload.merge({"iss" => sample_issuer, "aud" => "test-app"}) 914 | bad_token = JWT.encode(bad_payload, sample_rsa_private_pem, JWT::Algorithm::RS256, kid: "enc-key") 915 | 916 | # Create JWKS with use="enc" (should be rejected) 917 | enc_jwks = { 918 | keys: [{ 919 | kty: "RSA", 920 | use: "enc", 921 | kid: "enc-key", 922 | n: jwks.keys.first.n, 923 | e: jwks.keys.first.e, 924 | }], 925 | }.to_json 926 | 927 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 928 | .to_return(status: 200, body: { 929 | issuer: sample_issuer, 930 | jwks_uri: "#{sample_issuer}/bad-keys", 931 | }.to_json) 932 | 933 | WebMock.stub(:get, "#{sample_issuer}/bad-keys") 934 | .to_return(status: 200, body: enc_jwks) 935 | 936 | validator = JWT::JWKS.new 937 | validator.clear_cache 938 | 939 | # Should return nil (validation fails) 940 | result = validator.validate(bad_token, audience: "test-app") 941 | result.should be_nil 942 | end 943 | 944 | it "rejects tokens with incompatible key_ops" do 945 | # Create token signed with RS256 946 | bad_payload = sample_payload.merge({"iss" => sample_issuer, "aud" => "test-app"}) 947 | bad_token = JWT.encode(bad_payload, sample_rsa_private_pem, JWT::Algorithm::RS256, kid: "ops-key") 948 | 949 | # Create JWKS with key_ops that doesn't include "verify" 950 | ops_jwks = { 951 | keys: [{ 952 | kty: "RSA", 953 | key_ops: ["encrypt", "wrapKey"], 954 | kid: "ops-key", 955 | n: jwks.keys.first.n, 956 | e: jwks.keys.first.e, 957 | }], 958 | }.to_json 959 | 960 | WebMock.stub(:get, "#{sample_issuer}/.well-known/openid-configuration") 961 | .to_return(status: 200, body: { 962 | issuer: sample_issuer, 963 | jwks_uri: "#{sample_issuer}/bad-ops-keys", 964 | }.to_json) 965 | 966 | WebMock.stub(:get, "#{sample_issuer}/bad-ops-keys") 967 | .to_return(status: 200, body: ops_jwks) 968 | 969 | validator = JWT::JWKS.new 970 | validator.clear_cache 971 | 972 | # Should return nil (validation fails) 973 | result = validator.validate(bad_token, audience: "test-app") 974 | result.should be_nil 975 | end 976 | end 977 | end 978 | end 979 | --------------------------------------------------------------------------------