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..56fef2e
--- /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..5e5a80f 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,49 +33,68 @@ 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
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["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
- Authly.config.token_store.store(@access_token, access_payload)
+ # Add certificate thumbprint if provided
+ if cert_thumbprint
+ 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)
+ 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"] = REFRESH_TTL.from_now.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_token, refresh_payload)
- else
- # For JWT tokens, no need to store metadata (self-contained)
- generator.store_token_metadata(@jti, @client_id, @scope, sub, expires_at, cert_thumbprint)
+ Authly.config.token_store.store(refresh, refresh_payload)
end
end
end
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..818fa3c 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..a97a5da 100644
--- a/src/authly/handler.cr
+++ b/src/authly/handler.cr
@@ -19,10 +19,8 @@ module Authly
handle_token_endpoint(context)
when "/oauth/par"
handle_if_method(context, "POST") { PARHandler.handle(context) }
- when "/oauth/device/code"
- handle_if_method(context, "POST") { DeviceAuthorizationHandler.handle(context) }
- when "/device"
- 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"
@@ -46,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
@@ -60,7 +68,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..9104872 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
@@ -349,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
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