|
664 | 664 | assert flow.get("description") == "Passkey login flow" |
665 | 665 | ''; |
666 | 666 | }; |
| 667 | + |
| 668 | + # OIDC password-grant end-to-end: declared user authenticates against a |
| 669 | + # declared client, and the returned id_token + userinfo carry the |
| 670 | + # standard claims the runtime config promised. Validates the typed |
| 671 | + # options -> .tf.json -> Keycloak API -> OIDC token pipeline end-to-end, |
| 672 | + # not just the admin-API surface the per-family tests cover. |
| 673 | + keycloak-e2e = pkgs.testers.runNixOSTest { |
| 674 | + name = "declarative-keycloak-e2e"; |
| 675 | + |
| 676 | + containers.keycloak = mkHost { |
| 677 | + runtime = { |
| 678 | + realms.acme = { |
| 679 | + display_name = "ACME"; |
| 680 | + login_with_email_allowed = true; |
| 681 | + }; |
| 682 | + |
| 683 | + users.alice = { |
| 684 | + realm = "acme"; |
| 685 | + username = "alice"; |
| 686 | + email = "alice@acme.test"; |
| 687 | + first_name = "Alice"; |
| 688 | + last_name = "Tester"; |
| 689 | + enabled = true; |
| 690 | + email_verified = true; |
| 691 | + initial_password = { |
| 692 | + valueFile = "/etc/secrets/alice-pw"; |
| 693 | + temporary = false; |
| 694 | + }; |
| 695 | + }; |
| 696 | + |
| 697 | + # PUBLIC client, only direct access grants enabled (the password |
| 698 | + # grant doesn't use redirects, so no valid_redirect_uris and |
| 699 | + # standard/implicit flow off -- the provider rejects redirect |
| 700 | + # URIs without a flow that uses them). |
| 701 | + openid_clients.test_app = { |
| 702 | + realm = "acme"; |
| 703 | + client_id = "test-app"; |
| 704 | + name = "Test App"; |
| 705 | + access_type = "PUBLIC"; |
| 706 | + standard_flow_enabled = false; |
| 707 | + direct_access_grants_enabled = true; |
| 708 | + }; |
| 709 | + }; |
| 710 | + extraEtc = { |
| 711 | + "secrets/alice-pw".text = "hackme"; |
| 712 | + }; |
| 713 | + }; |
| 714 | + |
| 715 | + testScript = '' |
| 716 | + ${pyHelpers} |
| 717 | + import base64 |
| 718 | +
|
| 719 | + def jwt_claims(tok): |
| 720 | + # JWT = header.payload.signature; payload is urlsafe-base64 JSON |
| 721 | + # (no padding). pad to a multiple of 4 before decoding. |
| 722 | + payload = tok.split(".")[1] |
| 723 | + payload += "=" * (-len(payload) % 4) |
| 724 | + return json.loads(base64.urlsafe_b64decode(payload)) |
| 725 | +
|
| 726 | + start_all() |
| 727 | + keycloak.wait_for_unit("declarative-keycloak.service") |
| 728 | +
|
| 729 | + with subtest("password grant returns access + id token"): |
| 730 | + resp = json.loads(keycloak.succeed( |
| 731 | + "curl --fail -s -X POST " |
| 732 | + "http://localhost:8080/realms/acme/protocol/openid-connect/token " |
| 733 | + "-d grant_type=password " |
| 734 | + "-d client_id=test-app " |
| 735 | + "-d username=alice " |
| 736 | + "-d password=hackme " |
| 737 | + "--data-urlencode 'scope=openid email profile'" |
| 738 | + )) |
| 739 | + assert "access_token" in resp, f"no access_token in response: {resp}" |
| 740 | + assert "id_token" in resp, f"no id_token in response: {resp}" |
| 741 | + access_token = resp["access_token"] |
| 742 | + id_token = resp["id_token"] |
| 743 | +
|
| 744 | + with subtest("id_token claims match declared user attributes"): |
| 745 | + c = jwt_claims(id_token) |
| 746 | + assert c.get("preferred_username") == "alice", c |
| 747 | + assert c.get("email") == "alice@acme.test", c |
| 748 | + assert c.get("email_verified") is True, c |
| 749 | + assert c.get("given_name") == "Alice", c |
| 750 | + assert c.get("family_name") == "Tester", c |
| 751 | + assert c.get("name") == "Alice Tester", c |
| 752 | +
|
| 753 | + with subtest("userinfo endpoint matches the id_token claims"): |
| 754 | + ui = json.loads(keycloak.succeed( |
| 755 | + f"curl --fail -s -H 'Authorization: Bearer {access_token}' " |
| 756 | + "http://localhost:8080/realms/acme/protocol/openid-connect/userinfo" |
| 757 | + )) |
| 758 | + assert ui.get("preferred_username") == "alice", ui |
| 759 | + assert ui.get("email") == "alice@acme.test", ui |
| 760 | + assert ui.get("given_name") == "Alice", ui |
| 761 | + assert ui.get("family_name") == "Tester", ui |
| 762 | +
|
| 763 | + with subtest("login-with-email accepts the email as the username field"): |
| 764 | + resp2 = json.loads(keycloak.succeed( |
| 765 | + "curl --fail -s -X POST " |
| 766 | + "http://localhost:8080/realms/acme/protocol/openid-connect/token " |
| 767 | + "-d grant_type=password " |
| 768 | + "-d client_id=test-app " |
| 769 | + "-d username=alice@acme.test " |
| 770 | + "-d password=hackme " |
| 771 | + "--data-urlencode 'scope=openid'" |
| 772 | + )) |
| 773 | + assert "access_token" in resp2, f"email login rejected: {resp2}" |
| 774 | +
|
| 775 | + with subtest("wrong password is rejected with 401"): |
| 776 | + # `curl --fail` exits non-zero on >= 400, so use machine.fail. |
| 777 | + keycloak.fail( |
| 778 | + "curl --fail -s -X POST " |
| 779 | + "http://localhost:8080/realms/acme/protocol/openid-connect/token " |
| 780 | + "-d grant_type=password " |
| 781 | + "-d client_id=test-app " |
| 782 | + "-d username=alice " |
| 783 | + "-d password=wrong" |
| 784 | + ) |
| 785 | + ''; |
| 786 | + }; |
667 | 787 | } |
0 commit comments