From a168ce5ae544f6682ac302e473492b008932a6fd Mon Sep 17 00:00:00 2001 From: Elias Perez Date: Sat, 31 Oct 2026 15:39:21 -0400 Subject: [PATCH 1/2] Harden hosted issuer flows and add trusted token issuance controls --- README.md | 37 +++++ spec/access_token_spec.cr | 8 +- spec/authly_spec.cr | 2 +- spec/bearer_hardening_spec.cr | 2 +- spec/bearer_spec.cr | 4 +- spec/grant_spec.cr | 4 +- spec/grants/authorization_code_spec.cr | 2 +- spec/grants/refresh_token_hardening_spec.cr | 90 +++++------ spec/grants/refresh_token_spec.cr | 4 +- spec/handlers/discovery_handler_spec.cr | 1 + spec/handlers/introspect_revoke_auth_spec.cr | 4 +- spec/issuer_integration_spec.cr | 152 +++++++++++++++++++ spec/support/config_isolation.cr | 2 +- spec/support/handlers_spec.cr | 32 ++-- spec/token_strategy_spec.cr | 14 +- src/authly.cr | 25 ++- src/authly/access_token.cr | 56 ++++--- src/authly/code.cr | 13 +- src/authly/configuration.cr | 8 + src/authly/grant.cr | 11 +- src/authly/grants/authorization_code.cr | 3 + src/authly/grants/client_credentials.cr | 6 +- src/authly/handler.cr | 4 +- src/authly/handlers/access_token_handler.cr | 10 +- src/authly/handlers/authorization_handler.cr | 20 ++- src/authly/handlers/discovery_handler.cr | 14 +- src/authly/handlers/refresh_token_handler.cr | 5 +- src/authly/handlers/userinfo_handler.cr | 9 +- src/authly/issuance_options.cr | 18 +++ src/authly/jwks.cr | 6 +- src/authly/response_type.cr | 8 +- src/authly/token_generator.cr | 29 +++- src/authly/token_manager.cr | 4 +- src/authly/token_verifier.cr | 2 +- 34 files changed, 453 insertions(+), 156 deletions(-) create mode 100644 spec/issuer_integration_spec.cr create mode 100644 src/authly/issuance_options.cr diff --git a/README.md b/README.md index c7d1d3b..be80280 100644 --- a/README.md +++ b/README.md @@ -1462,3 +1462,40 @@ Distributed under the MIT License. See `LICENSE` for more information.
Made with ❤️ by the Crystal community
+ +### Host-controlled issuance and signing-key rotation + +`Authly::IssuanceOptions` lets trusted application code set a resource audience, +absolute expiration, and scalar application claims without changing global +configuration between requests. Reserved JWT fields cannot be overridden. + +```crystal +options = Authly::IssuanceOptions.new( + "https://api.example.test", Time.utc + 5.minutes, + {"sid" => "session-id"} of String => Authly::AdditionalClaim) +token = Authly::AccessToken.new("client-id", "read", subject: "account:42", options: options) +``` + +Explicit options default to no refresh token. Existing issuance retains refresh +behavior unless `config.issue_refresh_tokens = false`; `refresh_token` is now +nullable and omitted from JSON when disabled. Set `config.grant_types` to the +allowed grants as well; disabling issuance alone does not disable redemption. +`config.issuance_options` is an optional trusted `(client_id, grant_type)` callback +for token-endpoint issuance. Never populate its claims from unverified form data. + +For asymmetric rotation, set `signing_key_id`, the active private `secret_key`, +and `verification_keys` (a kid-to-public-PEM map containing active and retiring +keys). JWTs carry the active kid; verification rejects absent or unknown kids when +the map is configured. JWKS publishes only those public keys. Keep retiring keys +until every credential signed with them expires. + +`config.render_consent` receives the HTTP context, validated authorization request, +verified subject, and signed consent token. It may render the host UI, but approval +must POST the original token and decision to `/oauth/authorize`; it cannot bypass +subject binding or single-use CSRF protection. OIDC nonce is kept separate from +consent state and is preserved in the ID token. + +`config.userinfo` resolves a `Hash(String, JSON::Any)` from the verified subject, +including boolean claims such as `email_verified`. The resolver cannot replace +the authenticated subject or granted scopes. Account lifecycle, session validity, +resource policy, shared-store atomicity, and browser protection remain host duties. diff --git a/spec/access_token_spec.cr b/spec/access_token_spec.cr index a31542e..c53b5ad 100644 --- a/spec/access_token_spec.cr +++ b/spec/access_token_spec.cr @@ -30,8 +30,8 @@ module Authly it "generates refresh token along with access token" do token = AccessToken.new("client_123", "read") - token.refresh_token.should_not be_nil - token.refresh_token.should_not be_empty + token.refresh_token.not_nil!.should_not be_nil + token.refresh_token.not_nil!.should_not be_empty end it "sets expiration time based on configured TTL" do @@ -116,7 +116,7 @@ module Authly it "stores refresh token metadata separately" do token = AccessToken.new("client_123", "read") - refresh_payload = Authly.config.token_store.fetch(token.refresh_token) + refresh_payload = Authly.config.token_store.fetch(token.refresh_token.not_nil!) refresh_payload["sub"].should eq "client_123" refresh_payload["token_type"].should eq "refresh_token" end @@ -126,7 +126,7 @@ module Authly token2 = AccessToken.new("client_1", "read") token1.access_token.should_not eq token2.access_token - token1.refresh_token.should_not eq token2.refresh_token + token1.refresh_token.not_nil!.should_not eq token2.refresh_token.not_nil! end end diff --git a/spec/authly_spec.cr b/spec/authly_spec.cr index 25c456b..87fa1d1 100644 --- a/spec/authly_spec.cr +++ b/spec/authly_spec.cr @@ -75,7 +75,7 @@ describe Authly do grant_type: "refresh_token", client_id: client_id, client_secret: client_secret, - refresh_token: a_token.refresh_token.to_s) + refresh_token: a_token.refresh_token.not_nil!.to_s) token.should be_a Authly::AccessToken end diff --git a/spec/bearer_hardening_spec.cr b/spec/bearer_hardening_spec.cr index 29ede56..affd055 100644 --- a/spec/bearer_hardening_spec.cr +++ b/spec/bearer_hardening_spec.cr @@ -324,7 +324,7 @@ describe "access token audience and type" do other = BearerSpec.jwt(BearerSpec.payload({"token_type" => "id_token"})) [Authly::StrategyTokenVerifier.new, Authly::StrategyTokenVerifier.new(legacy_tokens: true)].each do |verifier| - verifier.verify(access.refresh_token).should be_nil + verifier.verify(access.refresh_token.not_nil!).should be_nil verifier.verify(other).should be_nil end end diff --git a/spec/bearer_spec.cr b/spec/bearer_spec.cr index d426495..cd9dbfa 100644 --- a/spec/bearer_spec.cr +++ b/spec/bearer_spec.cr @@ -111,7 +111,7 @@ describe Authly::Handler::Bearer do end it "rejects a refresh token presented as an access token" do - refresh = Authly::AccessToken.new("test_client", "read:users", subject: "alice").refresh_token + refresh = Authly::AccessToken.new("test_client", "read:users", subject: "alice").refresh_token.not_nil! BearerSpec.with_server(BearerSpec.chain) do |client| BearerSpec.get(client, "/users", "Bearer #{refresh}").status_code.should eq 401 end @@ -309,7 +309,7 @@ describe "Bearer with opaque tokens" do claims = verifier.verify(access.access_token) claims.try(&.subject).should eq "bob" claims.try(&.scopes).should eq ["read:users"] - verifier.verify(access.refresh_token).should be_nil + verifier.verify(access.refresh_token.not_nil!).should be_nil verifier.verify("0" * 64).should be_nil ensure config.token_strategy, config.token_store = previous diff --git a/spec/grant_spec.cr b/spec/grant_spec.cr index 03831d2..f8c329f 100644 --- a/spec/grant_spec.cr +++ b/spec/grant_spec.cr @@ -77,7 +77,7 @@ module Authly it "stays with the owner when the token is refreshed" do first = Grant.new("password", client_id: client_id, client_secret: client_secret, username: username, password: password).token - refreshed = Grant.new("refresh_token", client_id: client_id, client_secret: client_secret, refresh_token: first.refresh_token).token + refreshed = Grant.new("refresh_token", client_id: client_id, client_secret: client_secret, refresh_token: first.refresh_token.not_nil!).token refreshed.sub.should eq first.sub end @@ -97,7 +97,7 @@ module Authly grant = Grant.new("refresh_token", client_id: client_id, client_secret: client_secret, - refresh_token: AccessToken.new(client_id, "read").refresh_token) + refresh_token: AccessToken.new(client_id, "read").refresh_token.not_nil!) token = grant.token diff --git a/spec/grants/authorization_code_spec.cr b/spec/grants/authorization_code_spec.cr index 23e37c0..6a64c4a 100644 --- a/spec/grants/authorization_code_spec.cr +++ b/spec/grants/authorization_code_spec.cr @@ -89,7 +89,7 @@ module Authly code = Code.new(client_id, scope, uri).to_s authorization_code = AuthorizationCode.new("invalid_client", "invalid_client_secret", uri, code) - expect_raises Error, ERROR_MSG[:invalid_redirect_uri] do + expect_raises Error, ERROR_MSG[:invalid_grant] do authorization_code.authorized? end end diff --git a/spec/grants/refresh_token_hardening_spec.cr b/spec/grants/refresh_token_hardening_spec.cr index 6a9f019..3c38717 100644 --- a/spec/grants/refresh_token_hardening_spec.cr +++ b/spec/grants/refresh_token_hardening_spec.cr @@ -55,110 +55,110 @@ describe "Refresh token hardening" do it "refuses a revoked refresh token" do issued = Authly::AccessToken.new("1", "read") - Authly.revoke(issued.refresh_token) - RefreshSpec.rejected { RefreshSpec.refresh(issued.refresh_token) } + Authly.revoke(issued.refresh_token.not_nil!) + RefreshSpec.rejected { RefreshSpec.refresh(issued.refresh_token.not_nil!) } end end describe "client binding" do it "refuses a refresh token issued to another client" do issued = Authly::AccessToken.new("1", "read") - RefreshSpec.rejected { RefreshSpec.refresh(issued.refresh_token, client: "test_client", secret: "test_secret") } + RefreshSpec.rejected { RefreshSpec.refresh(issued.refresh_token.not_nil!, client: "test_client", secret: "test_secret") } end it "does not consume the token when another client tried it" do issued = Authly::AccessToken.new("1", "read") - RefreshSpec.rejected { RefreshSpec.refresh(issued.refresh_token, client: "test_client", secret: "test_secret") } - RefreshSpec.refresh(issued.refresh_token).access_token.should_not be_empty + RefreshSpec.rejected { RefreshSpec.refresh(issued.refresh_token.not_nil!, client: "test_client", secret: "test_secret") } + RefreshSpec.refresh(issued.refresh_token.not_nil!).access_token.should_not be_empty end end describe "rotation" do it "answers with a new refresh token and takes the old one out of use" do first = Authly::AccessToken.new("1", "read write", subject: "john") - second = RefreshSpec.refresh(first.refresh_token) + second = RefreshSpec.refresh(first.refresh_token.not_nil!) - second.refresh_token.should_not eq first.refresh_token - RefreshSpec.rejected { RefreshSpec.refresh(first.refresh_token) } + second.refresh_token.not_nil!.should_not eq first.refresh_token.not_nil! + RefreshSpec.rejected { RefreshSpec.refresh(first.refresh_token.not_nil!) } end it "lets the new refresh token be used in turn" do first = Authly::AccessToken.new("1", "read", subject: "john") - third = RefreshSpec.refresh(RefreshSpec.refresh(first.refresh_token).refresh_token) + third = RefreshSpec.refresh(RefreshSpec.refresh(first.refresh_token.not_nil!).refresh_token.not_nil!) third.access_token.should_not be_empty end it "redeems a refresh token once even when the store is asked twice" do issued = Authly::AccessToken.new("1", "read") - jti = Authly.jwt_decode(issued.refresh_token).first["jti"].as_s + jti = Authly.jwt_decode(issued.refresh_token.not_nil!).first["jti"].as_s results = Array.new(4) { Authly.config.token_store.consume(jti) } results.count(true).should eq 1 end it "revokes the whole family when a consumed refresh token is presented again" do first = Authly::AccessToken.new("1", "read", subject: "john") - second = RefreshSpec.refresh(first.refresh_token) + second = RefreshSpec.refresh(first.refresh_token.not_nil!) - RefreshSpec.rejected { RefreshSpec.refresh(first.refresh_token) } - RefreshSpec.rejected { RefreshSpec.refresh(second.refresh_token) } + RefreshSpec.rejected { RefreshSpec.refresh(first.refresh_token.not_nil!) } + RefreshSpec.rejected { RefreshSpec.refresh(second.refresh_token.not_nil!) } end it "keeps the subject of the original grant" do first = Authly::AccessToken.new("1", "read", subject: "john") - RefreshSpec.refresh(first.refresh_token).sub.should eq "john" + RefreshSpec.refresh(first.refresh_token.not_nil!).sub.should eq "john" end it "keeps other families alone" do mine = Authly::AccessToken.new("1", "read", subject: "john") other = Authly::AccessToken.new("1", "read", subject: "john") - RefreshSpec.refresh(mine.refresh_token) - RefreshSpec.rejected { RefreshSpec.refresh(mine.refresh_token) } - RefreshSpec.refresh(other.refresh_token).access_token.should_not be_empty + RefreshSpec.refresh(mine.refresh_token.not_nil!) + RefreshSpec.rejected { RefreshSpec.refresh(mine.refresh_token.not_nil!) } + RefreshSpec.refresh(other.refresh_token.not_nil!).access_token.should_not be_empty end end describe "scope" do it "carries the original scope into the refreshed token" do first = Authly::AccessToken.new("1", "read write", subject: "john") - RefreshSpec.refresh(first.refresh_token).scope.should eq "read write" + RefreshSpec.refresh(first.refresh_token.not_nil!).scope.should eq "read write" end it "keeps the original scope through every rotation" do first = Authly::AccessToken.new("1", "read write", subject: "john") - second = RefreshSpec.refresh(first.refresh_token, scope: "read") + second = RefreshSpec.refresh(first.refresh_token.not_nil!, scope: "read") second.scope.should eq "read" - RefreshSpec.refresh(second.refresh_token).scope.should eq "read write" + RefreshSpec.refresh(second.refresh_token.not_nil!).scope.should eq "read write" end it "narrows the scope on request" do first = Authly::AccessToken.new("1", "read write", subject: "john") - RefreshSpec.refresh(first.refresh_token, scope: "write").scope.should eq "write" + RefreshSpec.refresh(first.refresh_token.not_nil!, scope: "write").scope.should eq "write" end it "refuses a scope the original grant did not have" do first = Authly::AccessToken.new("1", "read", subject: "john") - RefreshSpec.rejected(:invalid_scope) { RefreshSpec.refresh(first.refresh_token, scope: "read admin") } + RefreshSpec.rejected(:invalid_scope) { RefreshSpec.refresh(first.refresh_token.not_nil!, scope: "read admin") } end it "does not consume the token when the scope is refused" do first = Authly::AccessToken.new("1", "read", subject: "john") - RefreshSpec.rejected(:invalid_scope) { RefreshSpec.refresh(first.refresh_token, scope: "admin") } - RefreshSpec.refresh(first.refresh_token).scope.should eq "read" + RefreshSpec.rejected(:invalid_scope) { RefreshSpec.refresh(first.refresh_token.not_nil!, scope: "admin") } + RefreshSpec.refresh(first.refresh_token.not_nil!).scope.should eq "read" end end describe "absolute lifetime" do it "stamps a new family with its end and never lets a token outlive it" do first = Authly::AccessToken.new("1", "read", subject: "john") - claims = RefreshSpec.claims(first.refresh_token) + claims = RefreshSpec.claims(first.refresh_token.not_nil!) claims["fexp"].as_i64.should be_close((Time.utc + Authly.config.refresh_absolute_ttl).to_unix, 5) claims["exp"].as_i64.should be <= claims["fexp"].as_i64 end it "carries the end of the family through every rotation" do first = Authly::AccessToken.new("1", "read", subject: "john") - second = RefreshSpec.refresh(first.refresh_token) - RefreshSpec.claims(second.refresh_token)["fexp"].should eq RefreshSpec.claims(first.refresh_token)["fexp"] + second = RefreshSpec.refresh(first.refresh_token.not_nil!) + RefreshSpec.claims(second.refresh_token.not_nil!)["fexp"].should eq RefreshSpec.claims(first.refresh_token.not_nil!)["fexp"] end it "keeps the end of the family when the setting changes in between" do @@ -167,11 +167,11 @@ describe "Refresh token hardening" do first = Authly::AccessToken.new("1", "read", subject: "john") Authly.config.refresh_absolute_ttl = 20.days begin - second = RefreshSpec.refresh(first.refresh_token) - third = RefreshSpec.refresh(second.refresh_token) - fexp = RefreshSpec.claims(first.refresh_token)["fexp"] - RefreshSpec.claims(second.refresh_token)["fexp"].should eq fexp - RefreshSpec.claims(third.refresh_token)["fexp"].should eq fexp + second = RefreshSpec.refresh(first.refresh_token.not_nil!) + third = RefreshSpec.refresh(second.refresh_token.not_nil!) + fexp = RefreshSpec.claims(first.refresh_token.not_nil!)["fexp"] + RefreshSpec.claims(second.refresh_token.not_nil!)["fexp"].should eq fexp + RefreshSpec.claims(third.refresh_token.not_nil!)["fexp"].should eq fexp ensure Authly.config.refresh_absolute_ttl = previous end @@ -181,7 +181,7 @@ describe "Refresh token hardening" do previous = Authly.config.refresh_absolute_ttl Authly.config.refresh_absolute_ttl = 30.minutes begin - claims = RefreshSpec.claims(Authly::AccessToken.new("1", "read", subject: "john").refresh_token) + claims = RefreshSpec.claims(Authly::AccessToken.new("1", "read", subject: "john").refresh_token.not_nil!) claims["exp"].as_i64.should eq claims["fexp"].as_i64 ensure Authly.config.refresh_absolute_ttl = previous @@ -201,30 +201,30 @@ describe "Refresh token hardening" do recent = RefreshSpec.forged({"token_type" => "refresh_token", "sub" => "john", "cid" => "1", "scope" => "read", "fid" => "recent", "iat" => Time.utc.to_unix}) - RefreshSpec.claims(RefreshSpec.refresh(recent).refresh_token)["fexp"]?.should_not be_nil + RefreshSpec.claims(RefreshSpec.refresh(recent).refresh_token.not_nil!)["fexp"]?.should_not be_nil end end describe "revoking a refresh token" do it "revokes its whole family" do first = Authly::AccessToken.new("1", "read", subject: "john") - second = RefreshSpec.refresh(first.refresh_token) + second = RefreshSpec.refresh(first.refresh_token.not_nil!) - Authly.revoke(first.refresh_token) - RefreshSpec.rejected { RefreshSpec.refresh(second.refresh_token) } + Authly.revoke(first.refresh_token.not_nil!) + RefreshSpec.rejected { RefreshSpec.refresh(second.refresh_token.not_nil!) } end it "keeps other families alone" do mine = Authly::AccessToken.new("1", "read", subject: "john") other = Authly::AccessToken.new("1", "read", subject: "john") - Authly.revoke(mine.refresh_token) - RefreshSpec.refresh(other.refresh_token).access_token.should_not be_empty + Authly.revoke(mine.refresh_token.not_nil!) + RefreshSpec.refresh(other.refresh_token.not_nil!).access_token.should_not be_empty end it "does not touch a family when an access token is revoked" do issued = Authly::AccessToken.new("1", "read", subject: "john") Authly.revoke(issued.access_token) - RefreshSpec.refresh(issued.refresh_token).access_token.should_not be_empty + RefreshSpec.refresh(issued.refresh_token.not_nil!).access_token.should_not be_empty end it "stores the family revocation for refresh_ttl in a store that takes a TTL" do @@ -233,8 +233,8 @@ describe "Refresh token hardening" do Authly.config.token_store = store begin issued = Authly::AccessToken.new("1", "read", subject: "john") - family = RefreshSpec.claims(issued.refresh_token)["fid"].as_s - Authly.revoke(issued.refresh_token) + family = RefreshSpec.claims(issued.refresh_token.not_nil!)["fid"].as_s + Authly.revoke(issued.refresh_token.not_nil!) store.ttls["refresh-family:#{family}"].should eq Authly.config.refresh_ttl ensure Authly.config.token_store = previous @@ -247,8 +247,8 @@ describe "Refresh token hardening" do Authly.config.token_store = store begin first = Authly::AccessToken.new("1", "read", subject: "john") - RefreshSpec.refresh(first.refresh_token) - RefreshSpec.rejected { RefreshSpec.refresh(first.refresh_token) } + RefreshSpec.refresh(first.refresh_token.not_nil!) + RefreshSpec.rejected { RefreshSpec.refresh(first.refresh_token.not_nil!) } store.ttls.values.should eq [Authly.config.refresh_ttl] ensure Authly.config.token_store = previous @@ -270,7 +270,7 @@ describe "Refresh token hardening" do describe "as an access token" do it "is refused by the userinfo endpoint" do - refresh = Authly::AccessToken.new("test_client", "openid profile", subject: "john").refresh_token + refresh = Authly::AccessToken.new("test_client", "openid profile", subject: "john").refresh_token.not_nil! io = IO::Memory.new response = HTTP::Server::Response.new(io) headers = HTTP::Headers{"Authorization" => "Bearer #{refresh}"} diff --git a/spec/grants/refresh_token_spec.cr b/spec/grants/refresh_token_spec.cr index 761de0e..d19630f 100644 --- a/spec/grants/refresh_token_spec.cr +++ b/spec/grants/refresh_token_spec.cr @@ -5,7 +5,7 @@ module Authly client_id, secret, scope = "1", "secret", "read write" it "returns RefreshToken" do - token = AccessToken.new(client_id, scope).refresh_token + token = AccessToken.new(client_id, scope).refresh_token.not_nil! refresh_token = RefreshToken.new( client_id: client_id, client_secret: secret, refresh_token: token, ) @@ -13,7 +13,7 @@ module Authly end it "raises error for invalid client credentials" do - token = AccessToken.new(client_id, scope).refresh_token + token = AccessToken.new(client_id, scope).refresh_token.not_nil! refresh_token = RefreshToken.new( client_id: client_id, client_secret: "BAD", refresh_token: token diff --git a/spec/handlers/discovery_handler_spec.cr b/spec/handlers/discovery_handler_spec.cr index 8daca4f..43289b1 100644 --- a/spec/handlers/discovery_handler_spec.cr +++ b/spec/handlers/discovery_handler_spec.cr @@ -3,6 +3,7 @@ require "../spec_helper" describe Authly::DiscoveryHandler do it "returns OpenID Connect discovery metadata" do Authly.config.base_url = "https://auth.example.com" + Authly.config.issuer = "https://auth.example.com" io = IO::Memory.new response = HTTP::Server::Response.new(io) diff --git a/spec/handlers/introspect_revoke_auth_spec.cr b/spec/handlers/introspect_revoke_auth_spec.cr index 89fd5d6..ca122ac 100644 --- a/spec/handlers/introspect_revoke_auth_spec.cr +++ b/spec/handlers/introspect_revoke_auth_spec.cr @@ -103,7 +103,7 @@ describe "client authentication at /introspect and /revoke (RFC 7662, RFC 7009)" end it "revokes a JWT refresh token for the client it was issued to, and not for another" do - refresh = Authly::AccessToken.new("1", "read", subject: "alice").refresh_token + refresh = Authly::AccessToken.new("1", "read", subject: "alice").refresh_token.not_nil! IntrospectRevokeSpec.post("/revoke", {"token" => refresh}, basic: "test_client:test_secret").status.should eq 200 Authly.revoked?(refresh).should be_false @@ -114,7 +114,7 @@ describe "client authentication at /introspect and /revoke (RFC 7662, RFC 7009)" it "revokes an opaque refresh token for the client it was issued to, and not for another" do IntrospectRevokeSpec.with_opaque_strategy do - refresh = Authly::AccessToken.new("1", "read", subject: "alice").refresh_token + refresh = Authly::AccessToken.new("1", "read", subject: "alice").refresh_token.not_nil! IntrospectRevokeSpec.post("/revoke", {"token" => refresh}, basic: "test_client:test_secret").status.should eq 200 Authly.revoked?(refresh).should be_false diff --git a/spec/issuer_integration_spec.cr b/spec/issuer_integration_spec.cr new file mode 100644 index 0000000..4440a34 --- /dev/null +++ b/spec/issuer_integration_spec.cr @@ -0,0 +1,152 @@ +require "./spec_helper" + +describe "hosted issuer integration" do + before_each do + Authly.config.token_store = Authly::InMemoryStore.new + Authly.config.state_store = Authly::InMemoryStateStore.new + end + + it "binds codes to their client even when another client shares the callback" do + Authly.clients << Authly::Client.new("other", "secret2", AuthorizationSecuritySpec::CALLBACK, "other", "read", "authorization_code") + code = Authly::Code.new("1", "read", AuthorizationSecuritySpec::CALLBACK).to_s + expect_raises(Authly::Error) do + Authly::AuthorizationCode.new("other", "secret2", AuthorizationSecuritySpec::CALLBACK, code).authorized? + end + Authly::AuthorizationCode.new("1", "secret", AuthorizationSecuritySpec::CALLBACK, code).authorized?.should be_true + end + + it "rejects a signed non-code credential even with matching code fields" do + original = Authly::Code.new("1", "read", AuthorizationSecuritySpec::CALLBACK).to_s + claims = Authly.jwt_decode(original)[0].as_h + claims["token_type"] = JSON::Any.new("access_token") + expect_raises(Authly::Error) do + Authly::AuthorizationCode.new("1", "secret", AuthorizationSecuritySpec::CALLBACK, Authly.jwt_encode(claims)).authorized? + end + end + + it "reads configured issuer and lifetimes when issuing credentials" do + Authly.config.issuer = "https://issuer.example" + Authly.config.code_ttl = 60.seconds + Authly.config.access_ttl = 5.minutes + code = Authly.jwt_decode(Authly::Code.new("1", "read", AuthorizationSecuritySpec::CALLBACK).to_s)[0] + code["exp"].as_i64.-(code["iat"].as_i64).should eq 60 + token = Authly::AccessToken.new("1", "read") + claims = Authly.jwt_decode(token.access_token)[0] + claims["iss"].as_s.should eq "https://issuer.example" + claims["exp"].as_i64.should eq token.expires_at + token.expires_in.should eq 300 + end + + it "carries the OIDC nonce through consent and redemption separately from CSRF state" do + response = AuthorizationSecuritySpec.approve(AuthorizationSecuritySpec.params(scope: "openid", nonce: "oidc-nonce"), "john") + code = URI.parse(response.location.not_nil!).query_params["code"] + token = Authly.access_token("authorization_code", client_id: "1", client_secret: "secret", redirect_uri: AuthorizationSecuritySpec::CALLBACK, code: code) + claims = Authly.jwt_decode(token.id_token.not_nil!)[0] + claims["nonce"].as_s.should eq "oidc-nonce" + claims["aud"].as_s.should eq "1" + end + + it "preserves machine scopes, refuses escalation, and can omit refresh tokens" do + Authly.config.issue_refresh_tokens = false + token = Authly.access_token("client_credentials", client_id: "1", client_secret: "secret", scope: "read write") + token.scope.should eq "read write" + JSON.parse(token.to_json)["refresh_token"]?.should be_nil + expect_raises(Authly::Error) do + Authly.access_token("client_credentials", client_id: "1", client_secret: "secret", scope: "unknown") + end + end + + it "applies per-issuance audience, expiry, and claims without mutating global settings" do + deadline = Time.utc + 30.seconds + options = Authly::IssuanceOptions.new("agent:production", deadline, {"sid" => "session-1", "browser" => true} of String => Authly::AdditionalClaim) + token = Authly::AccessToken.new("gateway", "operations:write", subject: "account:7", options: options) + claims = Authly.jwt_decode(token.access_token)[0] + claims["aud"].as_s.should eq "agent:production" + claims["exp"].as_i64.should eq deadline.to_unix + claims["sid"].as_s.should eq "session-1" + claims["browser"].as_bool.should be_true + token.refresh_token.should be_nil + Authly.config.access_ttl.should eq 1.hour + Authly::StrategyTokenVerifier.new(audience: "other").verify(token.access_token).should be_nil + Authly::StrategyTokenVerifier.new(audience: "agent:production").verify(token.access_token).should_not be_nil + end + + it "refuses reserved claim overrides" do + expect_raises(ArgumentError) do + Authly::IssuanceOptions.new("api", Time.utc + 1.minute, {"sub" => "victim"} of String => Authly::AdditionalClaim) + end + end + + it "rejects disabled grants before issuing credentials" do + Authly.config.grant_types = %w[authorization_code client_credentials] + expect_raises(Authly::Error) do + Authly.access_token("password", client_id: "1", client_secret: "secret", username: "john", password: "secret") + end + end + + it "authenticates token requests using HTTP Basic and returns standard JSON" do + probe = AuthorizationSecuritySpec::Probe.new("POST", "/oauth/token", form: {"grant_type" => "client_credentials", "scope" => "read"}) + probe.context.request.headers["Authorization"] = "Basic #{Base64.strict_encode("1:secret")}" + Authly::AccessTokenHandler.handle(probe.context) + probe.status.should eq 200 + result = JSON.parse(probe.body) + result["token_type"].as_s.should eq "Bearer" + result["scope"].as_s.should eq "read" + result["expires_in"].as_i64.should be > 0 + end + + it "selects only a configured signing key and accepts a retiring key" do + Authly.config.signing_key_id = "old" + Authly.config.verification_keys = {"old" => Authly.config.secret_key} + token = Authly::AccessToken.new("1", "read").access_token + Authly.config.secret_key = "new-signing-secret" + Authly.config.signing_key_id = "new" + Authly.config.verification_keys = Authly.config.verification_keys.merge({"new" => "new-signing-secret"}) + Authly::StrategyTokenVerifier.new.verify(token).should_not be_nil + Authly.config.verification_keys = {"new" => "new-signing-secret"} + Authly::StrategyTokenVerifier.new.verify(token).should be_nil + end +end + +describe "issuer extension hooks" do + it "publishes retiring RSA keys and signs with the active kid" do + old = OpenSSL::PKey::RSA.new(2048) + active = OpenSSL::PKey::RSA.new(2048) + config = Authly.config + config.algorithm = JWT::Algorithm::RS256 + config.secret_key = active.to_pem + config.public_key = active.public_key.to_pem + config.signing_key_id = "active" + config.verification_keys = {"active" => active.public_key.to_pem, "retiring" => old.public_key.to_pem} + token = Authly::AccessToken.new("client", "read") + Authly.jwt_decode(token.access_token)[1]["kid"].as_s.should eq "active" + Authly::JWKS.new.keys.map(&.["kid"]).sort.should eq %w[active retiring] + Authly::JWKS.new.to_json.should_not contain("PRIVATE") + end + + it "lets the host render consent without bypassing approval or the CSRF token" do + Authly.config.render_consent = ->(ctx : HTTP::Server::Context, req : Authly::AuthorizationRequest, subject : String, token : String) { + ctx.response.content_type = "application/json" + ctx.response << {client: req.client_id, subject: subject, consent_token: token}.to_json + nil + } + page = AuthorizationSecuritySpec.authorize(AuthorizationSecuritySpec.params(nonce: "client-nonce"), "john") + result = JSON.parse(page.body) + approval = AuthorizationSecuritySpec.decide({"consent_token" => result["consent_token"].as_s, "decision" => "approve"}, "john") + approval.status.should eq 302 + replay = AuthorizationSecuritySpec.decide({"consent_token" => result["consent_token"].as_s, "decision" => "approve"}, "john") + replay.status.should eq 400 + end + + it "loads UserInfo from the verified subject and does not let the hook replace it" do + Authly.config.userinfo = ->(subject : String) { {"sub" => JSON::Any.new("attacker"), "email" => JSON::Any.new("person@example.test"), "email_verified" => JSON::Any.new(true)} } + token = Authly::AccessToken.new("client", "openid email", subject: "account:1") + probe = AuthorizationSecuritySpec::Probe.new("GET", "/oauth/userinfo") + probe.context.request.headers["Authorization"] = "Bearer #{token.access_token}" + Authly::UserInfoHandler.handle(probe.context) + probe.status.should eq 200 + result = JSON.parse(probe.body) + result["sub"].as_s.should eq "account:1" + result["email_verified"].as_bool.should be_true + end +end diff --git a/spec/support/config_isolation.cr b/spec/support/config_isolation.cr index 122b867..30344d3 100644 --- a/spec/support/config_isolation.cr +++ b/spec/support/config_isolation.cr @@ -7,7 +7,7 @@ class Authly::Configuration # Takes over every setting of `from`; lists are copied, since a spec may append to one in place. def restore(from : Configuration) : Nil {% for ivar in @type.instance_vars %} - {% if ivar.type <= Array %} + {% if ivar.type <= Array || ivar.type <= Hash %} @{{ ivar.id }} = from.@{{ ivar.id }}.dup {% elsif ivar.id == "clients" %} @clients = copy_of(from.@clients) diff --git a/spec/support/handlers_spec.cr b/spec/support/handlers_spec.cr index a6eb5f9..b06b248 100644 --- a/spec/support/handlers_spec.cr +++ b/spec/support/handlers_spec.cr @@ -99,7 +99,7 @@ module Authly AccessTokenHandler.handle(test_ctx.context) test_ctx.context.response.status_code.should eq 400 - test_ctx.response_body.should eq "Invalid or unknown grant type" + JSON.parse(test_ctx.response_body)["error"].as_s.should eq "unsupported_grant_type" end end @@ -324,7 +324,7 @@ module Authly form_data = { "client_id" => "1", "client_secret" => "secret", - "refresh_token" => token.refresh_token, + "refresh_token" => token.refresh_token.not_nil!, } test_ctx = create_test_context("POST", "/oauth/refresh", form_data: form_data) RefreshTokenHandler.handle(test_ctx.context) @@ -335,7 +335,7 @@ module Authly body["refresh_token"].should_not be_nil end - it "returns 400 for missing client_id parameter" do + it "returns 401 for missing client_id parameter" do form_data = { "client_secret" => "secret", "refresh_token" => "token", @@ -343,12 +343,12 @@ module Authly test_ctx = create_test_context("POST", "/oauth/refresh", form_data: form_data) RefreshTokenHandler.handle(test_ctx.context) - test_ctx.context.response.status_code.should eq 400 + test_ctx.context.response.status_code.should eq 401 body = JSON.parse(test_ctx.response_body) - body["error"].should eq "missing_parameters" + body["error"].should eq "invalid_client" end - it "returns 400 for missing client_secret parameter" do + it "returns 401 for missing client_secret parameter" do form_data = { "client_id" => "1", "refresh_token" => "token", @@ -356,9 +356,9 @@ module Authly test_ctx = create_test_context("POST", "/oauth/refresh", form_data: form_data) RefreshTokenHandler.handle(test_ctx.context) - test_ctx.context.response.status_code.should eq 400 + test_ctx.context.response.status_code.should eq 401 body = JSON.parse(test_ctx.response_body) - body["error"].should eq "missing_parameters" + body["error"].should eq "invalid_client" end it "returns 400 for missing refresh_token parameter" do @@ -380,7 +380,7 @@ module Authly form_data = { "client_id" => "1", "client_secret" => "wrong_secret", - "refresh_token" => token.refresh_token, + "refresh_token" => token.refresh_token.not_nil!, } test_ctx = create_test_context("POST", "/oauth/refresh", form_data: form_data) RefreshTokenHandler.handle(test_ctx.context) @@ -436,16 +436,10 @@ module Authly describe "IntrospectHandler - Enhanced Coverage" do it "returns active=false for expired token" do - # Create a token with very short TTL - original_ttl = Authly.config.access_ttl - Authly.config.access_ttl = -1.seconds # Already expired - - token = Authly::AccessToken.new("1", "read") - - # Restore original TTL - Authly.config.access_ttl = original_ttl - - form_data = CLIENT_AUTH.merge({"token" => token.access_token}) + original = Authly::AccessToken.new("1", "read") + claims = Authly.jwt_decode(original.access_token)[0].as_h + claims["exp"] = JSON::Any.new((Time.utc - 1.second).to_unix) + form_data = CLIENT_AUTH.merge({"token" => Authly.jwt_encode(claims)}) test_ctx = create_test_context("POST", "/introspect", form_data: form_data) IntrospectHandler.handle(test_ctx.context) diff --git a/spec/token_strategy_spec.cr b/spec/token_strategy_spec.cr index 7c26927..d8571ec 100644 --- a/spec/token_strategy_spec.cr +++ b/spec/token_strategy_spec.cr @@ -28,7 +28,7 @@ module Authly token = AccessToken.new(client_id, scope) # JWT refresh tokens should be decodable - decoded, _header = Authly.jwt_decode(token.refresh_token) + decoded, _header = Authly.jwt_decode(token.refresh_token.not_nil!) decoded["sub"].should eq client_id Authly.config.token_strategy = original_strategy @@ -162,8 +162,8 @@ module Authly strategy = JWTToken.new strategy.valid?(issued.access_token).should be_true - strategy.valid?(issued.refresh_token).should be_false - strategy.introspect(issued.refresh_token).should eq({"active" => false}) + strategy.valid?(issued.refresh_token.not_nil!).should be_false + strategy.introspect(issued.refresh_token.not_nil!).should eq({"active" => false}) strategy.introspect(issued.access_token)["active"].should be_true end @@ -175,8 +175,8 @@ module Authly strategy = OpaqueToken.new strategy.valid?(issued.access_token).should be_true - strategy.valid?(issued.refresh_token).should be_false - strategy.introspect(issued.refresh_token).should eq({"active" => false}) + strategy.valid?(issued.refresh_token.not_nil!).should be_false + strategy.introspect(issued.refresh_token.not_nil!).should eq({"active" => false}) strategy.introspect(issued.access_token)["active"].should be_true ensure Authly.config.token_strategy = original @@ -204,8 +204,8 @@ module Authly it "are not valid through Authly.valid? and Authly.introspect" do issued = AccessToken.new("1", "read", subject: "john") - Authly.valid?(issued.refresh_token).should be_false - Authly.introspect(issued.refresh_token).should eq({"active" => false}) + Authly.valid?(issued.refresh_token.not_nil!).should be_false + Authly.introspect(issued.refresh_token.not_nil!).should eq({"active" => false}) end end end diff --git a/src/authly.cr b/src/authly.cr index 19e8f3b..91bfef7 100644 --- a/src/authly.cr +++ b/src/authly.cr @@ -42,14 +42,33 @@ module Authly Grant.new(grant_type, **args).token end - def self.jwt_encode(payload) - JWT.encode(payload, config.secret_key, config.algorithm) + def self.jwt_encode(payload, configuration : Configuration = config) + if kid = configuration.signing_key_id + JWT.encode(payload, configuration.secret_key, configuration.algorithm, kid: kid) + else + JWT.encode(payload, configuration.secret_key, configuration.algorithm) + end end - def self.jwt_decode(token, secret_key = config.public_key) + def self.jwt_decode(token) + decode_with(token, config) + end + + def self.jwt_decode(token, secret_key : String) JWT.decode(token, secret_key, config.algorithm, iss: config.issuer) end + def self.decode_with(token : String, configuration : Configuration) + if configuration.verification_keys.empty? + JWT.decode(token, configuration.public_key, configuration.algorithm, iss: configuration.issuer) + else + JWT.decode(token, algorithm: configuration.algorithm, iss: configuration.issuer) do |header, _payload| + kid = header["kid"]?.try(&.as_s?) + (kid && configuration.verification_keys[kid]?) || raise JWT::DecodeError.new("Unknown signing key") + end + end + end + def self.revoke(token) TokenManager.instance.revoke(token) end diff --git a/src/authly/access_token.cr b/src/authly/access_token.cr index f4f863c..5e43c76 100644 --- a/src/authly/access_token.cr +++ b/src/authly/access_token.cr @@ -1,8 +1,6 @@ module Authly struct AccessToken include JSON::Serializable - ACCESS_TTL = Authly.config.access_ttl - REFRESH_TTL = Authly.config.refresh_ttl # The subject the token was issued for: the resource owner, or the client itself for # the client credentials grant. A token whose grant identifies nobody gets a random one, @@ -14,13 +12,13 @@ module Authly getter token_type : String = "Bearer" getter scope : String # Lifetime in seconds, as RFC 6749 section 5.1 defines `expires_in`. - getter expires_in : Int64 = ACCESS_TTL.total_seconds.to_i64 + getter expires_in : Int64 # Absolute expiry as a Unix timestamp (the `exp` claim). @[JSON::Field(ignore: true)] - getter expires_at : Int64 = ACCESS_TTL.from_now.to_unix + getter expires_at : Int64 getter? revoked : Bool = false @[JSON::Field(emit_null: false)] - getter refresh_token : String + getter refresh_token : String? = nil @[JSON::Field(emit_null: false)] getter id_token : String? = nil @[JSON::Field(ignore: true)] @@ -35,25 +33,32 @@ module Authly # the original one when an access token was narrowed on refresh. `family_expires_at` is the end of that family # (a new family ends `refresh_absolute_ttl` from now). def initialize(@client_id : String, @scope : String, @id_token : String? = nil, cert_thumbprint : String? = nil, subject : String? = nil, - family : String? = nil, refresh_scope : String? = nil, family_expires_at : Time? = nil) + family : String? = nil, refresh_scope : String? = nil, family_expires_at : Time? = nil, options : IssuanceOptions? = nil) + issued_at = Time.utc + expiry = options.try(&.expires_at) || issued_at + Authly.config.access_ttl + @expires_at = expiry.to_unix + @expires_in = @expires_at - issued_at.to_unix + raise ArgumentError.new("Expiration must be in the future") unless @expires_in > 0 @sub = subject || Random::Secure.hex(32) @jti = Random::Secure.hex(32) generator = TokenGeneratorFactory.create - @access_token = generator.generate_access_token(@client_id, @scope, sub, @jti, cert_thumbprint) + @access_token = generator.generate_access_token(@client_id, @scope, sub, @jti, cert_thumbprint, options, issued_at, expiry) refresh_jti = Random::Secure.hex(32) - @refresh_token = generator.generate_refresh_token( - @client_id, refresh_jti, subject || @client_id, refresh_scope || @scope, family || Random::Secure.hex(16), - family_expires_at || Authly.config.refresh_absolute_ttl.from_now) + if options ? options.issue_refresh? : Authly.config.issue_refresh_tokens + @refresh_token = generator.generate_refresh_token( + @client_id, refresh_jti, subject || @client_id, refresh_scope || @scope, family || Random::Secure.hex(16), + family_expires_at || Authly.config.refresh_absolute_ttl.from_now) + end # Store metadata for opaque tokens (use access_token as key) - expires_at = ACCESS_TTL.from_now + expires_at = expiry if Authly.config.token_strategy == :opaque access_payload = {} of String => (String | Int64 | Bool | Float64) access_payload["cid"] = @client_id access_payload["scope"] = @scope access_payload["sub"] = sub - access_payload["iat"] = Time.utc.to_unix + access_payload["iat"] = issued_at.to_unix access_payload["exp"] = expires_at.to_unix access_payload["token_type"] = "access_token" Authly.config.token_audience.try { |audience| access_payload["aud"] = audience } @@ -64,17 +69,26 @@ module Authly access_payload["cnf"] = cert_thumbprint end + if options + access_payload["aud"] = options.audience + options.additional_claims.each do |key, value| + raise ArgumentError.new("Reserved token claim") if IssuanceOptions::RESERVED.includes?(key) + access_payload[key] = value + end + end Authly.config.token_store.store(@access_token, access_payload) - # Store refresh token metadata for opaque tokens - refresh_payload = {} of String => (String | Int64 | Bool | Float64) - refresh_payload["cid"] = @client_id - refresh_payload["sub"] = @client_id - refresh_payload["iat"] = Time.utc.to_unix - refresh_payload["exp"] = REFRESH_TTL.from_now.to_unix - refresh_payload["token_type"] = "refresh_token" - refresh_payload["jti"] = refresh_jti - Authly.config.token_store.store(@refresh_token, refresh_payload) + if refresh = @refresh_token + # Store refresh token metadata for opaque tokens + refresh_payload = {} of String => (String | Int64 | Bool | Float64) + refresh_payload["cid"] = @client_id + refresh_payload["sub"] = @client_id + refresh_payload["iat"] = Time.utc.to_unix + refresh_payload["exp"] = Authly.config.refresh_ttl.from_now.to_unix + refresh_payload["token_type"] = "refresh_token" + refresh_payload["jti"] = refresh_jti + Authly.config.token_store.store(refresh, refresh_payload) + end else # For JWT tokens, no need to store metadata (self-contained) generator.store_token_metadata(@jti, @client_id, @scope, sub, expires_at, cert_thumbprint) diff --git a/src/authly/code.cr b/src/authly/code.cr index 1a344a3..97d2a0c 100644 --- a/src/authly/code.cr +++ b/src/authly/code.cr @@ -1,8 +1,6 @@ module Authly struct Code include JSON::Serializable - CODE_TTL = Authly.config.code_ttl - ISSUER = Authly.config.issuer getter code : String = Random::Secure.hex(16), client_id : String, @@ -10,7 +8,8 @@ module Authly redirect_uri : String, challenge = "", method = "", - user_id = "" + user_id = "", + nonce = "" def initialize( @client_id, @@ -19,6 +18,7 @@ module Authly @challenge = "", @method = "", @user_id = "", + @nonce = "", ) end @@ -26,14 +26,17 @@ module Authly Authly.jwt_encode({ "jti" => Random::Secure.hex(32), "code" => code, + "client_id" => client_id, + "token_type" => "authorization_code", + "nonce" => nonce, "challenge" => challenge, "method" => method, "scope" => scope, "user_id" => user_id, "redirect_uri" => redirect_uri, "iat" => Time.utc.to_unix, - "iss" => ISSUER, - "exp" => CODE_TTL.from_now.to_unix, + "iss" => Authly.config.issuer, + "exp" => Authly.config.code_ttl.from_now.to_unix, }) end diff --git a/src/authly/configuration.cr b/src/authly/configuration.cr index 86fa8aa..836da7b 100644 --- a/src/authly/configuration.cr +++ b/src/authly/configuration.cr @@ -18,6 +18,14 @@ module Authly # signed-in owner here, from its own session. It is the ONLY source of the subject: `user_id` and # `consent` request parameters are never read. While it is unset those endpoints refuse every request. property authenticate_user : OwnerAuthenticator? = nil + property render_consent : Proc(HTTP::Server::Context, AuthorizationRequest, String, String, Nil)? = nil + property userinfo : Proc(String, Hash(String, JSON::Any))? = nil + # Trusted host callback; never populated from OAuth request parameters. + property issuance_options : Proc(String, String, IssuanceOptions)? = nil + property issue_refresh_tokens : Bool = true + property grant_types : Array(String) = %w[authorization_code client_credentials password refresh_token implicit urn:ietf:params:oauth:grant-type:device_code] + property signing_key_id : String? = nil + property verification_keys : Hash(String, String) = {} of String => String # Optional. When nobody is signed in, redirect to the URL this returns (a login page that sends the # owner back afterwards) instead of answering 401 `login_required`. diff --git a/src/authly/grant.cr b/src/authly/grant.cr index 7e0852a..cd9f946 100644 --- a/src/authly/grant.cr +++ b/src/authly/grant.cr @@ -42,6 +42,7 @@ module Authly class GrantFactory def self.create(grant_type : String, **args) : GrantStrategy + raise Error.unsupported_grant_type unless Authly.config.grant_types.includes?(grant_type) case grant_type when "authorization_code" AuthorizationCode.new( @@ -54,7 +55,8 @@ module Authly when "client_credentials" ClientCredentials.new( client_id: args[:client_id].to_s, - client_secret: args[:client_secret].to_s + client_secret: args[:client_secret].to_s, + scope: args.fetch(:scope, "").to_s ) when "password" Password.new( @@ -86,8 +88,10 @@ module Authly property code : String @client_id : String @cert_thumbprint : String? + @options : IssuanceOptions? def initialize(grant_type : String, **args) + @options = args[:options]? @grant_strategy = GrantFactory.create(grant_type, **args) @client_id = args.fetch(:client_id, "").to_s @code = args.fetch(:code, "").to_s @@ -108,13 +112,14 @@ module Authly private def access_token AccessToken.new( @grant_strategy.client_id, scope, generate_id_token, @cert_thumbprint, @grant_strategy.subject, - @grant_strategy.refresh_family, @grant_strategy.refresh_scope, @grant_strategy.refresh_family_expires_at) + @grant_strategy.refresh_family, @grant_strategy.refresh_scope, @grant_strategy.refresh_family_expires_at, options: @options) end private def generate_id_token - if !@code.empty? && scope.includes? "openid" + if !@code.empty? && scope.split.includes?("openid") user_id = auth_code["user_id"].as_s payload = Authly.owners.id_token(user_id) + payload["nonce"] = auth_code["nonce"].as_s unless auth_code["nonce"]?.try(&.as_s?).to_s.empty? payload["iss"] = Authly.config.issuer payload["aud"] = @client_id Authly.jwt_encode(payload) diff --git a/src/authly/grants/authorization_code.cr b/src/authly/grants/authorization_code.cr index 567b7c3..292b5bb 100644 --- a/src/authly/grants/authorization_code.cr +++ b/src/authly/grants/authorization_code.cr @@ -36,6 +36,7 @@ module Authly private def decode_code decoded = Authly.jwt_decode(@code).first + raise Error.invalid_grant unless decoded["client_id"]?.try(&.as_s?) == @client_id && decoded["token_type"]?.try(&.as_s?) == "authorization_code" @challenge = decoded["challenge"].as_s @method = decoded["method"].as_s @jti = decoded["jti"].as_s @@ -61,6 +62,8 @@ module Authly end private def verify_challenge! + raise Error.invalid_grant if Authly.config.enforce_pkce? && challenge.empty? + raise Error.invalid_grant if !challenge.empty? && Authly.config.enforce_pkce_s256? && method != "S256" return if challenge.empty? raise Error.unauthorized_client if verifier.empty? raise Error.unauthorized_client unless code_challenge.valid?(verifier) diff --git a/src/authly/grants/client_credentials.cr b/src/authly/grants/client_credentials.cr index c463f72..bdb18d3 100644 --- a/src/authly/grants/client_credentials.cr +++ b/src/authly/grants/client_credentials.cr @@ -7,7 +7,11 @@ module Authly client_id end - def initialize(@client_id, @client_secret) + def granted_scope : String? + @scope + end + + def initialize(@client_id, @client_secret, @scope : String = "") end def authorized? : Bool diff --git a/src/authly/handler.cr b/src/authly/handler.cr index 339aa02..5b6b5ac 100644 --- a/src/authly/handler.cr +++ b/src/authly/handler.cr @@ -20,8 +20,10 @@ module Authly when "/oauth/par" handle_if_method(context, "POST") { PARHandler.handle(context) } when "/oauth/device/code" + return false unless Authly.config.grant_types.includes?("urn:ietf:params:oauth:grant-type:device_code") handle_if_method(context, "POST") { DeviceAuthorizationHandler.handle(context) } when "/device" + return false unless Authly.config.grant_types.includes?("urn:ietf:params:oauth:grant-type:device_code") handle_if_methods(context, ["GET", "POST"]) { DeviceVerificationHandler.handle(context) } when "/oauth/introspect", "/introspect" handle_if_method(context, "POST") { IntrospectHandler.handle(context) } @@ -60,7 +62,7 @@ module Authly private def handle_token_endpoint(context) : Bool return false unless context.request.method == "POST" - if context.request.form_params["grant_type"] == "refresh_token" + if context.request.form_params["grant_type"]? == "refresh_token" RefreshTokenHandler.handle(context) else AccessTokenHandler.handle(context) diff --git a/src/authly/handlers/access_token_handler.cr b/src/authly/handlers/access_token_handler.cr index d612794..dfaef98 100644 --- a/src/authly/handlers/access_token_handler.cr +++ b/src/authly/handlers/access_token_handler.cr @@ -4,8 +4,10 @@ module Authly # Extracting request parameters params = context.request.form_params grant_type = params.fetch("grant_type", "") - client_id = params.fetch("client_id", "") - client_secret = params.fetch("client_secret", "") + raise Error.unsupported_grant_type unless Authly.config.grant_types.includes?(grant_type) + client_id = ClientAuthentication.authenticate(context) + return unless client_id + _, client_secret = ClientAuthentication.credentials(context) redirect_uri = params.fetch("redirect_uri", "") username = params.fetch("username", "") password = params.fetch("password", "") @@ -29,11 +31,13 @@ module Authly refresh_token: refresh_token, scope: scope, cert_thumbprint: cert_thumbprint, + options: Authly.config.issuance_options.try(&.call(client_id, grant_type)), ) + context.response.headers["Cache-Control"] = "no-store" ResponseHelper.write(context, 200, "application/json", access_token.to_json) rescue e : Error - ResponseHelper.write(context, e.code, "text/plain", e.message) + ResponseHelper.write(context, e.code, "application/json", {error: e.type.to_s}.to_json) end private def self.extract_certificate_thumbprint(context : HTTP::Server::Context) : String? diff --git a/src/authly/handlers/authorization_handler.cr b/src/authly/handlers/authorization_handler.cr index 8c6be9d..e747f11 100644 --- a/src/authly/handlers/authorization_handler.cr +++ b/src/authly/handlers/authorization_handler.cr @@ -12,19 +12,20 @@ module Authly scope : String, code_challenge : String, challenge_method : String, - state : String do + state : String, + nonce : String = "" do def self.from_params(params : Hash(String, String)) : self new( params.fetch("client_id", ""), params.fetch("redirect_uri", ""), params.fetch("response_type", ""), params.fetch("response_mode", ""), params.fetch("scope", ""), params.fetch("code_challenge", ""), - params.fetch("code_challenge_method", ""), params.fetch("state", "")) + params.fetch("code_challenge_method", ""), params.fetch("state", ""), params.fetch("nonce", "")) end def to_claims : Hash(String, String) { "client_id" => client_id, "redirect_uri" => redirect_uri, "response_type" => response_type, "response_mode" => response_mode, "scope" => scope, "code_challenge" => code_challenge, - "code_challenge_method" => challenge_method, "state" => state, + "code_challenge_method" => challenge_method, "state" => state, "nonce" => nonce, } end @@ -75,9 +76,13 @@ module Authly nonce = Random::Secure.hex(16) Authly.config.state_store.store(nonce, Authly.config.consent_ttl) - claims = request.to_claims.merge({"subject" => subject, "nonce" => nonce}) + claims = request.to_claims.merge({"subject" => subject, "consent_nonce" => nonce}) token = FormToken.issue(CONSENT, claims, Authly.config.consent_ttl) - ResponseHelper.write_page(context, 200, consent_page(context, request, subject, token)) + if renderer = Authly.config.render_consent + renderer.call(context, request, subject, token) + else + ResponseHelper.write_page(context, 200, consent_page(context, request, subject, token)) + end end private def self.decide(context, authenticator) @@ -87,7 +92,7 @@ module Authly claims = FormToken.verify(CONSENT, context.request.form_params.fetch("consent_token", "")) return invalid_consent(context, 400) unless claims return invalid_consent(context, 403) unless claims["subject"]? == subject - return invalid_consent(context, 400) unless consume_nonce(claims["nonce"]?) + return invalid_consent(context, 400) unless consume_nonce(claims["consent_nonce"]?) request = AuthorizationRequest.from_params(claims) validate!(request) @@ -152,6 +157,7 @@ module Authly private def self.validate!(request : AuthorizationRequest) : Nil raise Error.invalid_redirect_uri if request.redirect_uri.empty? raise Error.invalid_redirect_uri unless Authly.clients.valid_redirect?(request.client_id, request.redirect_uri) + raise Error.unsupported_grant_type unless Authly.config.grant_types.includes?(grant_type(request)) raise Error.invalid_response_type unless ["code", "token"].includes?(request.response_type) raise Error(400).new(:unauthorized_client) unless Authly.clients.allowed_grant_type?(request.client_id, grant_type(request)) @@ -177,7 +183,7 @@ module Authly respond_with_token(context, request, token, use_jarm) else code = Authly.code(request.response_type, request.client_id, request.redirect_uri, request.scope, - request.code_challenge, request.challenge_method, subject) + request.code_challenge, request.challenge_method, subject, request.nonce) respond_with_code(context, request, code.to_s, use_jarm) end end diff --git a/src/authly/handlers/discovery_handler.cr b/src/authly/handlers/discovery_handler.cr index affe926..174eb25 100644 --- a/src/authly/handlers/discovery_handler.cr +++ b/src/authly/handlers/discovery_handler.cr @@ -4,7 +4,7 @@ module Authly base_url = Authly.config.base_url metadata = { - issuer: base_url, + issuer: Authly.config.issuer, authorization_endpoint: "#{base_url}/oauth/authorize", token_endpoint: "#{base_url}/oauth/token", userinfo_endpoint: "#{base_url}/oauth/userinfo", @@ -15,22 +15,14 @@ module Authly device_authorization_endpoint: "#{base_url}/oauth/device/code", pushed_authorization_request_endpoint: "#{base_url}/oauth/par", scopes_supported: ["openid", "profile", "email", "address", "phone", "offline_access"], - response_types_supported: ["code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token"], + response_types_supported: Authly.config.grant_types.includes?("implicit") ? ["code", "token"] : ["code"], response_modes_supported: ["query", "fragment", "form_post", "query.jwt", "fragment.jwt", "form_post.jwt", "jwt"], - grant_types_supported: [ - "authorization_code", - "implicit", - "password", - "client_credentials", - "refresh_token", - "urn:ietf:params:oauth:grant-type:device_code", - ], + grant_types_supported: Authly.config.grant_types, subject_types_supported: ["public"], id_token_signing_alg_values_supported: supported_algorithms, token_endpoint_auth_methods_supported: [ "client_secret_basic", "client_secret_post", - "none", ], code_challenge_methods_supported: pkce_methods, claims_supported: [ diff --git a/src/authly/handlers/refresh_token_handler.cr b/src/authly/handlers/refresh_token_handler.cr index b36c8b7..dea7969 100644 --- a/src/authly/handlers/refresh_token_handler.cr +++ b/src/authly/handlers/refresh_token_handler.cr @@ -2,8 +2,9 @@ module Authly class RefreshTokenHandler def self.handle(context : HTTP::Server::Context) # Extract parameters from the request - client_id = context.request.form_params["client_id"]? - client_secret = context.request.form_params["client_secret"]? + client_id = ClientAuthentication.authenticate(context) + return unless client_id + _, client_secret = ClientAuthentication.credentials(context) refresh_token = context.request.form_params["refresh_token"]? # Ensure all required parameters are present diff --git a/src/authly/handlers/userinfo_handler.cr b/src/authly/handlers/userinfo_handler.cr index e2f51c3..3cbfa4c 100644 --- a/src/authly/handlers/userinfo_handler.cr +++ b/src/authly/handlers/userinfo_handler.cr @@ -35,7 +35,14 @@ module Authly end # Extract user information based on scope - user_info = extract_user_info(token_data) + if resolver = Authly.config.userinfo + source = resolver.call(token_data["sub"].as_s) + source["sub"] = token_data["sub"] + source["scope"] = token_data["scope"] + user_info = extract_user_info(source) + else + user_info = extract_user_info(token_data) + end ResponseHelper.write(context, 200, "application/json", user_info.to_json) rescue e : Error diff --git a/src/authly/issuance_options.cr b/src/authly/issuance_options.cr new file mode 100644 index 0000000..6397cf4 --- /dev/null +++ b/src/authly/issuance_options.cr @@ -0,0 +1,18 @@ +module Authly + alias AdditionalClaim = String | Int64 | Bool | Float64 + + struct IssuanceOptions + RESERVED = %w[iss sub aud cid jti iat exp nbf scope token_type cnf] + getter audience : String + getter expires_at : Time + getter additional_claims : Hash(String, AdditionalClaim) + getter? issue_refresh : Bool + + def initialize(@audience, @expires_at, additional_claims = {} of String => AdditionalClaim, @issue_refresh = false) + raise ArgumentError.new("Audience is required") if @audience.blank? + raise ArgumentError.new("Expiration must be in the future") unless @expires_at > Time.utc + raise ArgumentError.new("Reserved token claim") if additional_claims.keys.any? { |key| RESERVED.includes?(key) } + @additional_claims = additional_claims.dup + end + end +end diff --git a/src/authly/jwks.cr b/src/authly/jwks.cr index d6f2a16..cf6113e 100644 --- a/src/authly/jwks.cr +++ b/src/authly/jwks.cr @@ -17,7 +17,11 @@ module Authly name = algorithm_name return [] of Hash(String, String) unless name - [PublicKey.parse(@config.public_key).to_jwk(name)] + if @config.verification_keys.empty? + [PublicKey.parse(@config.public_key).to_jwk(name).tap { |key| @config.signing_key_id.try { |id| key["kid"] = id } }] + else + @config.verification_keys.map { |id, pem| PublicKey.parse(pem).to_jwk(name).merge({"kid" => id}) } + end end def to_json(json : JSON::Builder) : Nil diff --git a/src/authly/response_type.cr b/src/authly/response_type.cr index f82c132..86f4eac 100644 --- a/src/authly/response_type.cr +++ b/src/authly/response_type.cr @@ -8,7 +8,8 @@ module Authly scope : String = "", challenge : String = "", challenge_method : String = "", - user_id : String = "" + user_id : String = "", + nonce : String = "" def initialize( @type, @@ -18,6 +19,7 @@ module Authly @challenge = "", @challenge_method = "", @user_id = "", + @nonce = "", ) end @@ -33,7 +35,7 @@ module Authly end def code - Code.new client_id, scope, redirect_uri, challenge, challenge_method, user_id + Code.new client_id, scope, redirect_uri, challenge, challenge_method, user_id, nonce end def token @@ -46,7 +48,7 @@ module Authly private def grant_type_authorized? grant_type = @type == "code" ? "authorization_code" : "implicit" - Authly.clients.allowed_grant_type?(client_id, grant_type) + Authly.config.grant_types.includes?(grant_type) && Authly.clients.allowed_grant_type?(client_id, grant_type) end end end diff --git a/src/authly/token_generator.cr b/src/authly/token_generator.cr index 3c0c7eb..1aa350c 100644 --- a/src/authly/token_generator.cr +++ b/src/authly/token_generator.cr @@ -1,6 +1,12 @@ module Authly module TokenGenerator abstract def generate_access_token(client_id : String, scope : String, sub : String, jti : String, cert_thumbprint : String? = nil) : String + + def generate_access_token(client_id : String, scope : String, sub : String, jti : String, cert_thumbprint : String?, options : IssuanceOptions?, issued_at : Time, expires_at : Time) : String + raise Error.unsupported_token_type if options + generate_access_token(client_id, scope, sub, jti, cert_thumbprint) + end + abstract def generate_refresh_token(client_id : String, jti : String) : String # A refresh token that remembers who it was issued for, so the access token a refresh @@ -34,16 +40,20 @@ module Authly end def generate_access_token(client_id : String, scope : String, sub : String, jti : String, cert_thumbprint : String? = nil) : String + generate_access_token(client_id, scope, sub, jti, cert_thumbprint, nil, Time.utc, @config.access_ttl.from_now) + end + + def generate_access_token(client_id : String, scope : String, sub : String, jti : String, cert_thumbprint : String?, options : IssuanceOptions?, issued_at : Time, expires_at : Time) : String payload = { "sub" => sub, "iss" => @config.issuer, "token_type" => "access_token", "cid" => client_id, - "iat" => Time.utc.to_unix, - "exp" => @config.access_ttl.from_now.to_unix, + "iat" => issued_at.to_unix, + "exp" => expires_at.to_unix, "scope" => scope, "jti" => jti, - } of String => (String | Int64 | Hash(String, String)) + } of String => (String | Int64 | Bool | Float64 | Hash(String, String)) @config.token_audience.try { |audience| payload["aud"] = audience } @@ -52,7 +62,14 @@ module Authly payload["cnf"] = {"x5t#S256" => cert_thumbprint} end - Authly.jwt_encode(payload) + if options + payload["aud"] = options.audience + options.additional_claims.each do |key, value| + raise ArgumentError.new("Reserved token claim") if IssuanceOptions::RESERVED.includes?(key) + payload[key] = value + end + end + Authly.jwt_encode(payload, @config) end def generate_refresh_token(client_id : String, jti : String) : String @@ -98,6 +115,10 @@ module Authly Random::Secure.hex(32) end + def generate_access_token(client_id : String, scope : String, sub : String, jti : String, cert_thumbprint : String?, options : IssuanceOptions?, issued_at : Time, expires_at : Time) : String + Random::Secure.hex(32) + end + def generate_refresh_token(client_id : String, jti : String) : String Random::Secure.hex(32) end diff --git a/src/authly/token_manager.cr b/src/authly/token_manager.cr index b9a9319..19d994b 100644 --- a/src/authly/token_manager.cr +++ b/src/authly/token_manager.cr @@ -60,7 +60,7 @@ module Authly def valid?(token) : Bool decoded_token, _header = Authly.jwt_decode(token) - return false if decoded_token["token_type"]?.try(&.as_s?) == REFRESH_TOKEN + return false unless decoded_token["token_type"]?.try(&.as_s?) == "access_token" return false if revoked?(token) exp = decoded_token["exp"].to_s.to_i64 @@ -75,7 +75,7 @@ module Authly payload, _header = Authly.jwt_decode(token) # A refresh token is not an access token (it is redeemed at the token endpoint only) - return {"active" => false} if payload["token_type"]?.try(&.as_s?) == REFRESH_TOKEN + return {"active" => false} unless payload["token_type"]?.try(&.as_s?) == "access_token" # Check if the token is revoked if revoked?(token) diff --git a/src/authly/token_verifier.cr b/src/authly/token_verifier.cr index 0149247..97f8e86 100644 --- a/src/authly/token_verifier.cr +++ b/src/authly/token_verifier.cr @@ -49,7 +49,7 @@ module Authly end private def load_jwt(token : String) : Claims? - payload, _header = JWT.decode(token, @config.public_key, @config.algorithm, iss: @config.issuer) + payload, _header = Authly.decode_with(token, @config) claims = Claims.from(payload.as_h) claims if claims.id.try { |jti| !revoked?(jti) } end From 065715821865bf1068be5db74f697e87b121036e Mon Sep 17 00:00:00 2001 From: "Elias J. Perez" Date: Sat, 31 Oct 2026 16:03:42 -0400 Subject: [PATCH 2/2] Refactor token handling and device authorization endpoints; improve configuration for refresh token issuance --- spec/issuer_integration_spec.cr | 4 +- src/authly/access_token.cr | 77 ++++++++++---------- src/authly/configuration.cr | 2 +- src/authly/handler.cr | 18 +++-- src/authly/handlers/authorization_handler.cr | 5 +- 5 files changed, 59 insertions(+), 47 deletions(-) diff --git a/spec/issuer_integration_spec.cr b/spec/issuer_integration_spec.cr index 4440a34..56fef2e 100644 --- a/spec/issuer_integration_spec.cr +++ b/spec/issuer_integration_spec.cr @@ -120,7 +120,7 @@ describe "issuer extension hooks" do config.verification_keys = {"active" => active.public_key.to_pem, "retiring" => old.public_key.to_pem} token = Authly::AccessToken.new("client", "read") Authly.jwt_decode(token.access_token)[1]["kid"].as_s.should eq "active" - Authly::JWKS.new.keys.map(&.["kid"]).sort.should eq %w[active retiring] + Authly::JWKS.new.keys.map(&.["kid"]).sort!.should eq %w[active retiring] Authly::JWKS.new.to_json.should_not contain("PRIVATE") end @@ -139,7 +139,7 @@ describe "issuer extension hooks" do end it "loads UserInfo from the verified subject and does not let the hook replace it" do - Authly.config.userinfo = ->(subject : String) { {"sub" => JSON::Any.new("attacker"), "email" => JSON::Any.new("person@example.test"), "email_verified" => JSON::Any.new(true)} } + Authly.config.userinfo = ->(_subject : String) { {"sub" => JSON::Any.new("attacker"), "email" => JSON::Any.new("person@example.test"), "email_verified" => JSON::Any.new(true)} } token = Authly::AccessToken.new("client", "openid email", subject: "account:1") probe = AuthorizationSecuritySpec::Probe.new("GET", "/oauth/userinfo") probe.context.request.headers["Authorization"] = "Bearer #{token.access_token}" diff --git a/src/authly/access_token.cr b/src/authly/access_token.cr index 5e43c76..5e5a80f 100644 --- a/src/authly/access_token.cr +++ b/src/authly/access_token.cr @@ -45,53 +45,56 @@ module Authly @access_token = generator.generate_access_token(@client_id, @scope, sub, @jti, cert_thumbprint, options, issued_at, expiry) refresh_jti = Random::Secure.hex(32) - if options ? options.issue_refresh? : Authly.config.issue_refresh_tokens + if options ? options.issue_refresh? : Authly.config.issue_refresh_tokens? @refresh_token = generator.generate_refresh_token( @client_id, refresh_jti, subject || @client_id, refresh_scope || @scope, family || Random::Secure.hex(16), family_expires_at || Authly.config.refresh_absolute_ttl.from_now) end - # Store metadata for opaque tokens (use access_token as key) - expires_at = expiry if Authly.config.token_strategy == :opaque - access_payload = {} of String => (String | Int64 | Bool | Float64) - access_payload["cid"] = @client_id - access_payload["scope"] = @scope - access_payload["sub"] = sub - access_payload["iat"] = issued_at.to_unix - access_payload["exp"] = expires_at.to_unix - access_payload["token_type"] = "access_token" - Authly.config.token_audience.try { |audience| access_payload["aud"] = audience } - access_payload["jti"] = @jti + store_opaque_metadata(issued_at, expiry, cert_thumbprint, options, refresh_jti) + else + # For JWT tokens, no need to store metadata (self-contained) + generator.store_token_metadata(@jti, @client_id, @scope, sub, expiry, cert_thumbprint) + end + end - # Add certificate thumbprint if provided - if cert_thumbprint - access_payload["cnf"] = cert_thumbprint - end + # Store metadata for opaque tokens (use access_token as key) + private def store_opaque_metadata(issued_at : Time, expires_at : Time, cert_thumbprint : String?, options : IssuanceOptions?, refresh_jti : String) + access_payload = {} of String => (String | Int64 | Bool | Float64) + access_payload["cid"] = @client_id + access_payload["scope"] = @scope + access_payload["sub"] = sub + access_payload["iat"] = issued_at.to_unix + access_payload["exp"] = expires_at.to_unix + access_payload["token_type"] = "access_token" + Authly.config.token_audience.try { |audience| access_payload["aud"] = audience } + access_payload["jti"] = @jti - if options - access_payload["aud"] = options.audience - options.additional_claims.each do |key, value| - raise ArgumentError.new("Reserved token claim") if IssuanceOptions::RESERVED.includes?(key) - access_payload[key] = value - end - end - Authly.config.token_store.store(@access_token, access_payload) + # Add certificate thumbprint if provided + if cert_thumbprint + access_payload["cnf"] = cert_thumbprint + end - if refresh = @refresh_token - # Store refresh token metadata for opaque tokens - refresh_payload = {} of String => (String | Int64 | Bool | Float64) - refresh_payload["cid"] = @client_id - refresh_payload["sub"] = @client_id - refresh_payload["iat"] = Time.utc.to_unix - refresh_payload["exp"] = Authly.config.refresh_ttl.from_now.to_unix - refresh_payload["token_type"] = "refresh_token" - refresh_payload["jti"] = refresh_jti - Authly.config.token_store.store(refresh, refresh_payload) + if options + access_payload["aud"] = options.audience + options.additional_claims.each do |key, value| + raise ArgumentError.new("Reserved token claim") if IssuanceOptions::RESERVED.includes?(key) + access_payload[key] = value end - else - # For JWT tokens, no need to store metadata (self-contained) - generator.store_token_metadata(@jti, @client_id, @scope, sub, expires_at, cert_thumbprint) + end + Authly.config.token_store.store(@access_token, access_payload) + + if refresh = @refresh_token + # Store refresh token metadata for opaque tokens + refresh_payload = {} of String => (String | Int64 | Bool | Float64) + refresh_payload["cid"] = @client_id + refresh_payload["sub"] = @client_id + refresh_payload["iat"] = Time.utc.to_unix + refresh_payload["exp"] = Authly.config.refresh_ttl.from_now.to_unix + refresh_payload["token_type"] = "refresh_token" + refresh_payload["jti"] = refresh_jti + Authly.config.token_store.store(refresh, refresh_payload) end end end diff --git a/src/authly/configuration.cr b/src/authly/configuration.cr index 836da7b..818fa3c 100644 --- a/src/authly/configuration.cr +++ b/src/authly/configuration.cr @@ -22,7 +22,7 @@ module Authly property userinfo : Proc(String, Hash(String, JSON::Any))? = nil # Trusted host callback; never populated from OAuth request parameters. property issuance_options : Proc(String, String, IssuanceOptions)? = nil - property issue_refresh_tokens : Bool = true + property? issue_refresh_tokens : Bool = true property grant_types : Array(String) = %w[authorization_code client_credentials password refresh_token implicit urn:ietf:params:oauth:grant-type:device_code] property signing_key_id : String? = nil property verification_keys : Hash(String, String) = {} of String => String diff --git a/src/authly/handler.cr b/src/authly/handler.cr index 5b6b5ac..a97a5da 100644 --- a/src/authly/handler.cr +++ b/src/authly/handler.cr @@ -19,12 +19,8 @@ module Authly handle_token_endpoint(context) when "/oauth/par" handle_if_method(context, "POST") { PARHandler.handle(context) } - when "/oauth/device/code" - return false unless Authly.config.grant_types.includes?("urn:ietf:params:oauth:grant-type:device_code") - handle_if_method(context, "POST") { DeviceAuthorizationHandler.handle(context) } - when "/device" - return false unless Authly.config.grant_types.includes?("urn:ietf:params:oauth:grant-type:device_code") - handle_if_methods(context, ["GET", "POST"]) { DeviceVerificationHandler.handle(context) } + when "/oauth/device/code", "/device" + handle_device_endpoint(context, path) when "/oauth/introspect", "/introspect" handle_if_method(context, "POST") { IntrospectHandler.handle(context) } when "/oauth/revoke", "/revoke" @@ -48,6 +44,16 @@ module Authly end end + private def handle_device_endpoint(context, path : String) : Bool + return false unless Authly.config.grant_types.includes?("urn:ietf:params:oauth:grant-type:device_code") + + if path == "/oauth/device/code" + handle_if_method(context, "POST") { DeviceAuthorizationHandler.handle(context) } + else + handle_if_methods(context, ["GET", "POST"]) { DeviceVerificationHandler.handle(context) } + end + end + private def handle_if_method(context, expected_method : String, &) return false unless context.request.method == expected_method yield diff --git a/src/authly/handlers/authorization_handler.cr b/src/authly/handlers/authorization_handler.cr index e747f11..9104872 100644 --- a/src/authly/handlers/authorization_handler.cr +++ b/src/authly/handlers/authorization_handler.cr @@ -355,7 +355,10 @@ module Authly id_token: id_token ) - # Return response based on mode + write_jarm_response(context, redirect_uri, mode, jwt_response) + end + + private def self.write_jarm_response(context, redirect_uri : String, mode : String, jwt_response : String) case mode when "query.jwt" # JWT in query parameter