Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1462,3 +1462,40 @@ Distributed under the MIT License. See `LICENSE` for more information.
<div align="center">
Made with ❤️ by the Crystal community
</div>

### 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.
8 changes: 4 additions & 4 deletions spec/access_token_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion spec/authly_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion spec/bearer_hardening_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions spec/bearer_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions spec/grant_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion spec/grants/authorization_code_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 45 additions & 45 deletions spec/grants/refresh_token_hardening_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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}"}
Expand Down
4 changes: 2 additions & 2 deletions spec/grants/refresh_token_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ 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,
)
refresh_token.authorized?.should be_truthy
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
Expand Down
1 change: 1 addition & 0 deletions spec/handlers/discovery_handler_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading