-
Notifications
You must be signed in to change notification settings - Fork 205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
JWT issuer validation #6175
base: main
Are you sure you want to change the base?
JWT issuer validation #6175
Conversation
c5c28c9
to
b656996
Compare
8d2f033
to
ff4e113
Compare
b2bc164
to
a66369a
Compare
As noted above, kept the old tables but moved them under "Legacy" namespace. We can work with them if needed but kept them explicitly obsolete. |
180e94a
to
4c1885e
Compare
66dcd1e
to
a5d6e0b
Compare
Do not allow issuers to provide keys with mismatching constraints. Thus, issuers with a particular domain should specify key iss token constraints for that domain.
a5d6e0b
to
0097276
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Partial review - need to take another look at the C++ changes.
service_keys = get_jwt_keys(args, primary) | ||
assert service_keys["kid1"]["issuers"][issuer.name] == issuer.name | ||
|
||
keys["keys"][0]["issuer"] = "https://issuer.com" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add a comment or brief LOG.info
explaining this line? It looks like it's setting a permitted constraint (in contrast to the later garbage
constraints), but its not obvious to me why we're setting this nested field directly.
pass | ||
else: | ||
assert False, f"Constraint {constraint} must not be allowed" | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
network.consortium.remove_jwt_issuer(primary, issuer.name) |
The other tests remove their temporary issuer to clean up the network before closing - should this do the same?
|
||
def try_auth(primary, issuer, kid, iss, tid): | ||
with primary.client("user0") as c: | ||
LOG.info("Calling JWT with kid from issuer for tenant") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LOG.info("Calling JWT with kid from issuer for tenant") | |
LOG.info(f"Creating JWT with kid={kid} from issuer={iss} for tenant={tid}") |
COMMNON_ISSUER = "https://login.microsoftonline.com/{tenantid}/v2.0" | ||
TENANT_ID = "9188050d-6c67-4c5b-b112-36a304b66da" | ||
ISSUER_TENANT = f"https://login.microsoftonline.com/{TENANT_ID}/v2.0" | ||
ANOTHER_TENANT_ID = "ANOTHER-6c67-4c5b-b112-36a304b66da" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ANOTHER_TENANT_ID = "ANOTHER-6c67-4c5b-b112-36a304b66da" | |
ANOTHER_TENANT_ID = "aaaaaaa-6c67-4c5b-b112-36a304b66da" |
Nit: Although we treat these as strings for now, they're really GUIDs - we should use a string of the right shape so that it will still work if we add more validation later. Hopefully it's clear that this is still an invalid/constructed GUID.
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp: | ||
jwt_cert_der = infra.crypto.cert_pem_to_der(issuer.cert_pem) | ||
der_b64 = base64.b64encode(jwt_cert_der).decode("ascii") | ||
data = { | ||
"issuer": issuer.issuer_url, | ||
"auto_refresh": False, | ||
"jwks": { | ||
"keys": [ | ||
{ | ||
"kty": "RSA", | ||
"kid": jwt_kid, | ||
"x5c": [der_b64], | ||
"issuer": ISSUER_TENANT, | ||
} | ||
] | ||
}, | ||
} | ||
json.dump(data, metadata_fp) | ||
metadata_fp.flush() | ||
network.consortium.set_jwt_issuer(primary, metadata_fp.name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We do this block several times - can we pull it out to a helper function?
bool validate_issuer( | ||
const http::JwtVerifier::Token& token, std::string issuer) | ||
{ | ||
LOG_INFO_FMT( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LOG_INFO_FMT( | |
if (!token.payload_typed.tid.has_value()) | |
{ | |
return false; | |
} | |
LOG_INFO_FMT( |
We check this condition at line 50, and in the return
statement. I think we can just early-out here if there's no tenant ID specified, and simplify the later conditions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I guess this would need to go after the is_microsoft_entra
check? I'll keep reading and work out how tid
is populated.
// limited.facebok.com | ||
// .facebok.com |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// limited.facebok.com | |
// .facebok.com | |
// limited.facebook.com | |
// .facebook.com |
const auto start_seek = | ||
issuer_domain.size() - (constraint_domain.size() + 1); | ||
const auto count_seek = constraint_domain.size() + 1; | ||
const auto pattern = "." + constraint_domain; | ||
|
||
return start_seek > 0 // at least one letter preceeds .issuer.domain | ||
&& issuer_domain.substr(start_seek, count_seek) == pattern; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this be replaced with ends_with()
?
const auto start_seek = | |
issuer_domain.size() - (constraint_domain.size() + 1); | |
const auto count_seek = constraint_domain.size() + 1; | |
const auto pattern = "." + constraint_domain; | |
return start_seek > 0 // at least one letter preceeds .issuer.domain | |
&& issuer_domain.substr(start_seek, count_seek) == pattern; | |
return issuer_domain.ends_with("." + constraint_domain); |
auto keys = tx.rw<JwtPublicSigningKeys>(Tables::JWT_PUBLIC_SIGNING_KEYS); | ||
auto key_issuer = | ||
tx.rw<JwtPublicSigningKeyIssuer>(Tables::JWT_PUBLIC_SIGNING_KEY_ISSUER); | ||
auto keys = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should keep the original code as well here, to remove entries from the Legacy
tables.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason we don't delete anything (here and in other places where you've commented) from the old tables in this revision is partially described here
Example. Nodes=[A,B,C]
A->A' (upgrades)
A becomes / remains a leader
A fetches new keys and deletes them from both table revisions
A goes down
B becomes a leader
Now, if we clean both old issuers+certs, we just stop authenticating. If we leave kids only - we can't serve remove/set keys properly because kids and issuers went async.
We could've only introduced new issuers table, however we did introduce both new certs (matches the previous schema) and new issuers.
The reason for that was to keep issuers+cert synced for both prev/new code, and to support the scenario described above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, I understand and agree now - not worth peppering any remove()
calls to legacy tables throughout the current code, because we don't want to try and precisely match the old write semantics. Instead we will deal with the old legacy state by adding a clear()
call to the legacy tables, in a future release.
value.constraint = it->second; | ||
} | ||
|
||
LOG_INFO_FMT( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LOG_INFO_FMT( | |
LOG_DEBUG_FMT( |
I think INFO
is too verbose for this, and will lead to a lot of logging spam in the output.
else | ||
{ | ||
auto identity = std::make_unique<JwtAuthnIdentity>(); | ||
identity->key_issuer = key_issuers->get(key_id).value(); | ||
identity->key_issuer = validated_issuer; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We never use the Legacy::JWT_PUBLIC_SIGNING_KEY_ISSUER
table, which means (if I'm reading right) that this validated_issuer
string will still be empty, if issuers
is empty and we went through the fallback_keys
path? Can we add a path in the fallback case above, something like:
if (!issuers)
{
auto fallback_issuer = tx.ro<...>(ccf::Tables::Legacy::JWT_PUBLIC_SIGNING_KEY_ISSUER);
validated_issuer = fallback_issuer->get(key_id).value_or({});
}
Or maybe we set issuers
to a single-entry vector containing the value from the legacy map, and go through this validation path? Whatever makes sense, but I think we should try harder to use the fallback values if we're doing any fallback handling here.
Eventually closes
Decided to merge together to avoid being split into different releases