Skip to content

Commit 0c9a573

Browse files
authored
Let a JWT policy require the token type it accepts (#1161)
Signed-off-by: Juan Cruz Viotti <jv@jviotti.com>
1 parent f35079b commit 0c9a573

15 files changed

Lines changed: 380 additions & 23 deletions

File tree

docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ same response as any other unauthenticated request.
474474
| `/issuer` | String | :red_circle: **Yes** | N/A | The token issuer to trust, matched against the `iss` claim |
475475
| `/audience` | String | :red_circle: **Yes** | N/A | The audience this instance identifies as. A token is accepted when its `aud` claim includes this value, so a token minted for several audiences at once is accepted as long as this one is among them |
476476
| `/algorithms` | Array | :red_circle: **Yes** | N/A | The JSON Web Signature algorithms the policy accepts. One or more of `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`, and `EdDSA` |
477+
| `/tokenType` | String | No | Any type is accepted | The `typ` header a presented token must carry, such as `at+jwt` for the [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068) JSON Web Token access token profile. Set it whenever the issuer stamps one. An identity token is signed by the same issuer under the same key, and where this policy's `audience` matches the `clientId` of an `oidc` policy on that issuer, the type is the only thing distinguishing the two, so without it an identity token is accepted as an API credential |
477478
| `/jwksUri` | String | No | Discovered from the issuer | The URL of the issuer's JSON Web Key Set. When omitted, it is discovered from the issuer's OpenID Connect metadata at `{issuer}/.well-known/openid-configuration`, which requires the issuer to be an `https` URL that publishes a valid OpenID Provider metadata document. Set it explicitly for an issuer that does not meet that bar |
478479

479480
For example, the following instance keeps `/docs` public, gates `/partners`

enterprise/authentication/authentication.cc

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ struct JWTPolicy {
310310
std::string_view issuer;
311311
std::string_view audience;
312312
std::string_view jwks_uri;
313+
std::string_view token_type;
313314
std::vector<sourcemeta::core::JWSAlgorithm> algorithms;
314315
};
315316

@@ -318,7 +319,8 @@ auto decode_jwt_metadata(const std::span<const std::byte> metadata,
318319
std::size_t cursor{0};
319320
if (!read_string(metadata, cursor, result.issuer) ||
320321
!read_string(metadata, cursor, result.audience) ||
321-
!read_string(metadata, cursor, result.jwks_uri)) {
322+
!read_string(metadata, cursor, result.jwks_uri) ||
323+
!read_string(metadata, cursor, result.token_type)) {
322324
return false;
323325
}
324326

@@ -359,15 +361,25 @@ auto collect_jwt_identifiers(const std::span<const std::byte> metadata,
359361
std::string_view issuer;
360362
std::string_view audience;
361363
std::string_view jwks_uri;
364+
std::string_view token_type;
362365
if (!read_string(metadata, cursor, issuer) ||
363366
!read_string(metadata, cursor, audience) ||
364-
!read_string(metadata, cursor, jwks_uri)) {
367+
!read_string(metadata, cursor, jwks_uri) ||
368+
!read_string(metadata, cursor, token_type)) {
365369
return;
366370
}
367371

368372
keys.emplace(issuer);
369373
keys.emplace(audience);
370374
keys.emplace(jwks_uri);
375+
// A policy that requires a token type admits a narrower set than one that
376+
// does not, so two policies alike but for it are not the same audience.
377+
// This refuses a reference from the stricter of the two to the looser one,
378+
// which every holder of the stricter credential could have followed anyway.
379+
// That is deliberate: the comparison is by equality, and the cost of
380+
// refusing is a build that has to say so, against disclosing a referent to
381+
// somebody the referrer never admitted
382+
keys.emplace(token_type);
371383

372384
std::uint32_t count{0};
373385
if (!read_u32(metadata, cursor, count) || count > metadata.size() - cursor) {
@@ -599,14 +611,18 @@ struct Authentication::Impl {
599611
return false;
600612
}
601613

602-
// The token type is not pinned, so a policy whose audience coincides with
603-
// an interactive policy's client identifier on the same issuer admits an
604-
// identity token as an API credential. Pinning it is a configuration
605-
// question rather than a default, since a provider that does not stamp the
606-
// type would otherwise stop working on upgrade
614+
// RFC 9068 Section 4 has a resource server refuse a token whose `typ` is
615+
// not the access token profile's, which is what keeps an identity token
616+
// from being spent as an API credential. A provider that does not stamp
617+
// the header at all cannot be told apart that way, so the policy says
618+
// which type it requires rather than one being assumed
619+
const auto expected_type{
620+
policy.token_type.empty()
621+
? std::optional<std::string_view>{std::nullopt}
622+
: std::optional<std::string_view>{policy.token_type}};
607623
const auto error{provider->verify(token, policy.algorithms, policy.issuer,
608624
policy.audience, std::nullopt,
609-
std::nullopt)};
625+
expected_type)};
610626
return !error.has_value();
611627
}
612628

enterprise/authentication/authentication_format.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
namespace sourcemeta::one {
99

1010
constexpr std::uint32_t AUTHENTICATION_MAGIC{0x48545541};
11-
constexpr std::uint32_t AUTHENTICATION_VERSION{9};
11+
constexpr std::uint32_t AUTHENTICATION_VERSION{10};
1212

1313
// The artifact begins with this header. Every variable-length section is
1414
// located through an absolute byte offset so the matcher can address it

enterprise/authentication/authentication_save.cc

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,13 @@ auto encode_oidc_metadata(const std::string_view issuer,
9898
auto encode_jwt_metadata(
9999
const std::string_view issuer, const std::string_view audience,
100100
const std::string_view jwks_uri,
101-
const std::span<const sourcemeta::core::JWSAlgorithm> algorithms)
102-
-> std::vector<std::byte> {
101+
const std::span<const sourcemeta::core::JWSAlgorithm> algorithms,
102+
const std::string_view token_type) -> std::vector<std::byte> {
103103
std::vector<std::byte> result;
104104
append_string(result, issuer);
105105
append_string(result, audience);
106106
append_string(result, jwks_uri);
107+
append_string(result, token_type);
107108
append_u32(result, static_cast<std::uint32_t>(algorithms.size()));
108109
for (const auto algorithm : algorithms) {
109110
result.push_back(
@@ -224,8 +225,9 @@ auto Authentication::save(std::span<const Authentication::Policy> policies,
224225
for (const auto &policy : policies) {
225226
std::vector<std::byte> policy_metadata;
226227
if (policy.type == Authentication::Type::JWT) {
227-
policy_metadata = encode_jwt_metadata(policy.issuer, policy.audience,
228-
policy.jwks_uri, policy.algorithms);
228+
policy_metadata =
229+
encode_jwt_metadata(policy.issuer, policy.audience, policy.jwks_uri,
230+
policy.algorithms, policy.token_type);
229231
} else if (policy.type == Authentication::Type::OIDC) {
230232
// A nameless interactive policy could never match a session cookie, and
231233
// one without a session secret could never mint or verify one, so both

enterprise/e2e/auth/hurl/directory.all.hurl

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ header "ETag" exists
1616
jsonpath "$.path" == "/"
1717
jsonpath "$.url" == "{{base}}"
1818
jsonpath "$.breadcrumb" count == 0
19-
jsonpath "$.schemas" == 72
19+
jsonpath "$.schemas" == 73
2020
jsonpath "$.policies" count == 0
21-
jsonpath "$.entries" count == 11
21+
jsonpath "$.entries" count == 12
2222
jsonpath "$.entries[0].name" == "console"
2323
jsonpath "$.entries[0].type" == "directory"
2424
jsonpath "$.entries[0].path" == "/console/"
@@ -97,6 +97,13 @@ jsonpath "$.entries[10].policies" count == 1
9797
jsonpath "$.entries[10].policies[0].name" == "telemetry"
9898
jsonpath "$.entries[10].policies[0].title" == "telemetry"
9999
jsonpath "$.entries[10].policies[0].type" == "jwt"
100+
jsonpath "$.entries[11].name" == "typed"
101+
jsonpath "$.entries[11].type" == "directory"
102+
jsonpath "$.entries[11].path" == "/typed/"
103+
jsonpath "$.entries[11].policies" count == 1
104+
jsonpath "$.entries[11].policies[0].name" == "typed"
105+
jsonpath "$.entries[11].policies[0].title" == "typed"
106+
jsonpath "$.entries[11].policies[0].type" == "jwt"
100107

101108
POST {{base}}/self/v1/api/schemas/evaluate{{root_listing_schema}}
102109
```
@@ -300,11 +307,12 @@ Cache-Control: public, max-age=0, must-revalidate
300307
[Asserts]
301308
header "Content-Type" contains "text/html"
302309
xpath "string(//title)" != "Unauthorized"
303-
xpath "count(//i[contains(@class, 'bi-lock-fill')])" == 8
310+
xpath "count(//i[contains(@class, 'bi-lock-fill')])" == 9
304311
xpath "count(//i[contains(@class, 'bi-folder-fill')])" == 3
305312
xpath "count(//tr[.//a[normalize-space(.) = 'console']]//i[contains(@class, 'bi-lock-fill')])" == 1
306313
xpath "count(//tr[.//a[normalize-space(.) = 'private']]//i[contains(@class, 'bi-lock-fill')])" == 1
307314
xpath "count(//tr[.//a[normalize-space(.) = 'internal']]//i[contains(@class, 'bi-lock-fill')])" == 1
315+
xpath "count(//tr[.//a[normalize-space(.) = 'typed']]//i[contains(@class, 'bi-lock-fill')])" == 1
308316
xpath "count(//tr[.//a[normalize-space(.) = 'reports']]//i[contains(@class, 'bi-lock-fill')])" == 1
309317
xpath "count(//tr[.//a[normalize-space(.) = 'mixed']]//i[contains(@class, 'bi-lock-fill')])" == 1
310318
xpath "count(//tr[.//a[normalize-space(.) = 'machine']]//i[contains(@class, 'bi-lock-fill')])" == 1
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# A machine token and a person's identity token are both signed by the same
2+
# provider under the same key, and where a policy names an audience that
3+
# matches an interactive client's identifier, everything else about them agrees
4+
# too. RFC 9068 Section 4 has a resource server refuse a token whose `typ` is
5+
# not the access token profile's, which Section 5 says is what keeps the two
6+
# apart. A provider that stamps no type cannot be told apart that way, so a
7+
# policy says which type it requires rather than one being assumed.
8+
#
9+
# These two clients differ in exactly that: same realm, same key, same
10+
# algorithm, same audience, and one stamps the profile's type while the other
11+
# does not. So a refusal here is attributable to the type alone.
12+
13+
# The client that stamps the type gets a token carrying it
14+
POST https://keycloak:8443/realms/main/protocol/openid-connect/token
15+
[FormParams]
16+
grant_type: client_credentials
17+
client_id: ci-typed
18+
client_secret: ci-typed-secret
19+
HTTP 200
20+
[Captures]
21+
typed_token: jsonpath "$.access_token"
22+
[Asserts]
23+
jsonpath "$.token_type" == "Bearer"
24+
jsonpath "$.access_token" matches /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/
25+
26+
# The client that does not stamp it gets one that is otherwise the same
27+
POST https://keycloak:8443/realms/main/protocol/openid-connect/token
28+
[FormParams]
29+
grant_type: client_credentials
30+
client_id: ci-untyped
31+
client_secret: ci-untyped-secret
32+
HTTP 200
33+
[Captures]
34+
untyped_token: jsonpath "$.access_token"
35+
[Asserts]
36+
jsonpath "$.token_type" == "Bearer"
37+
38+
# The policy requires the profile's type, so the token carrying it is admitted
39+
GET {{base}}/typed/thing.json
40+
Authorization: Bearer {{typed_token}}
41+
HTTP 200
42+
Cache-Control: private, max-age=0, must-revalidate
43+
Content-Type: application/schema+json
44+
Link: <https://json-schema.org/draft/2020-12/schema>; rel="describedby"
45+
Access-Control-Allow-Origin: *
46+
Access-Control-Expose-Headers: Link, ETag
47+
[Asserts]
48+
header "ETag" exists
49+
{
50+
"$schema": "https://json-schema.org/draft/2020-12/schema",
51+
"$id": "{{base}}/typed/thing",
52+
"type": "boolean"
53+
}
54+
55+
# And the one without it is refused, though it is valid in every other respect
56+
GET {{base}}/typed/thing.json
57+
Authorization: Bearer {{untyped_token}}
58+
HTTP 401
59+
Cache-Control: no-store
60+
Content-Type: application/problem+json
61+
WWW-Authenticate: Bearer realm="registry"
62+
Link: </self/v1/schemas/api/error>; rel="describedby"
63+
Access-Control-Allow-Origin: *
64+
Access-Control-Expose-Headers: Link, ETag
65+
[Captures]
66+
denied_body: body
67+
denied_schema: header "Link" regex "<([^>]+)>"
68+
[Asserts]
69+
header "ETag" not exists
70+
{
71+
"type": "urn:sourcemeta:one:authentication-required",
72+
"title": "Unauthorized",
73+
"status": 401,
74+
"detail": "This resource requires authentication"
75+
}
76+
77+
POST {{base}}/self/v1/api/schemas/evaluate{{denied_schema}}
78+
```
79+
{{denied_body}}
80+
```
81+
HTTP 200
82+
[Asserts]
83+
jsonpath "$.valid" == true
84+
85+
# Carrying nothing is refused identically, so the untyped token discloses
86+
# nothing by being refused differently
87+
GET {{base}}/typed/thing.json
88+
HTTP 401
89+
Cache-Control: no-store
90+
Content-Type: application/problem+json
91+
WWW-Authenticate: Bearer realm="registry"
92+
Link: </self/v1/schemas/api/error>; rel="describedby"
93+
[Asserts]
94+
body == {{denied_body}}
95+
96+
# The gated listing is not enumerable without the right token either
97+
GET {{base}}/self/v1/api/list/typed
98+
Authorization: Bearer {{untyped_token}}
99+
HTTP 401
100+
Cache-Control: no-store
101+
Content-Type: application/problem+json
102+
WWW-Authenticate: Bearer realm="registry"
103+
Link: </self/v1/schemas/api/error>; rel="describedby"
104+
[Asserts]
105+
body == {{denied_body}}
106+
107+
# The token that carries the type enumerates it, and the listing names the
108+
# policy that governs
109+
GET {{base}}/self/v1/api/list/typed
110+
Authorization: Bearer {{typed_token}}
111+
HTTP 200
112+
Cache-Control: private, max-age=0, must-revalidate
113+
Content-Type: application/json
114+
Link: </self/v1/schemas/api/list/response>; rel="describedby"
115+
Access-Control-Allow-Origin: *
116+
Access-Control-Expose-Headers: Link, ETag
117+
[Captures]
118+
listing_body: body
119+
listing_schema: header "Link" regex "<([^>]+)>"
120+
[Asserts]
121+
jsonpath "$.path" == "/typed"
122+
jsonpath "$.url" == "{{base}}/typed"
123+
jsonpath "$.schemas" == 1
124+
jsonpath "$.breadcrumb" count == 1
125+
jsonpath "$.breadcrumb[0].name" == "typed"
126+
jsonpath "$.breadcrumb[0].path" == "/typed/"
127+
jsonpath "$.policies" count == 1
128+
jsonpath "$.policies[0].name" == "typed"
129+
jsonpath "$.policies[0].title" == "typed"
130+
jsonpath "$.policies[0].type" == "jwt"
131+
jsonpath "$.entries" count == 1
132+
jsonpath "$.entries[0].name" == "thing"
133+
jsonpath "$.entries[0].type" == "schema"
134+
jsonpath "$.entries[0].path" == "/typed/thing"
135+
jsonpath "$.entries[0].identifier" == "{{base}}/typed/thing"
136+
jsonpath "$.entries[0].policies" count == 1
137+
jsonpath "$.entries[0].policies[0].name" == "typed"
138+
jsonpath "$.entries[0].policies[0].title" == "typed"
139+
jsonpath "$.entries[0].policies[0].type" == "jwt"
140+
141+
POST {{base}}/self/v1/api/schemas/evaluate{{listing_schema}}
142+
```
143+
{{listing_body}}
144+
```
145+
HTTP 200
146+
[Asserts]
147+
jsonpath "$.valid" == true
148+
149+
# A policy that requires no type is unaffected, so a provider that stamps none
150+
# keeps working
151+
GET {{base}}/machine/config.json
152+
Authorization: Bearer {{untyped_token}}
153+
HTTP 401
154+
Cache-Control: no-store
155+
Content-Type: application/problem+json
156+
WWW-Authenticate: Bearer realm="registry"
157+
[Asserts]
158+
body == {{denied_body}}

enterprise/e2e/auth/one.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@
6767
"jwksUri": "https://keycloak:8443/realms/main/protocol/openid-connect/certs",
6868
"algorithms": [ "RS256" ]
6969
},
70+
{
71+
"type": "jwt",
72+
"name": "typed",
73+
"paths": [ "/typed" ],
74+
"issuer": "https://keycloak:8443/realms/main",
75+
"audience": "https://typed.example.com",
76+
"tokenType": "at+jwt",
77+
"algorithms": [ "RS256" ]
78+
},
7079
{
7180
"type": "jwt",
7281
"name": "strict",
@@ -106,6 +115,7 @@
106115
"machine": { "path": "./schemas/machine" },
107116
"telemetry": { "path": "./schemas/telemetry" },
108117
"strict": { "path": "./schemas/strict" },
118+
"typed": { "path": "./schemas/typed" },
109119
"console": { "path": "./schemas/console" }
110120
}
111121
}

0 commit comments

Comments
 (0)